Skip to content

perf: eliminate per-RPC control-plane polling, cache auth/clients, and optimize proxy framing - #211

Merged
dborowitz merged 6 commits into
dborowitz-timingfrom
dborowitz-optimization
Oct 28, 2026
Merged

dborowitz merged 6 commits into
dborowitz-timingfrom
dborowitz-optimization

Conversation

@dborowitz

@dborowitz dborowitz commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Note

Stacked on #210 (dborowitz-timing).

Summary

Eliminates client-side control-plane, authentication, proxy framing, session attachment, polling, and import-time bottlenecks across google-cloud-spark-connect.

Prior to this series, even simple interactive Spark Connect operations suffered from severe client-side overhead:

  1. Every unary and streaming Spark Connect gRPC call (plus every background ReleaseExecute cleanup RPC) synchronously called GetSession on the Dataproc control plane and constructed a fresh SessionControllerClient from scratch.
  2. Every control-plane call and WebSocket connection spawned gcloud / mTLS subprocesses (google.auth.default()) and unconditionally refreshed OAuth2 tokens (creds.refresh()).
  3. The local TCP-to-WebSocket proxy (ManagedSparkSessionProxy) read in 1 KiB chunks (recv(1024)), compressed already-compressed Arrow/protobuf frames with single-threaded Python zlib (permessage-deflate), left Nagle's algorithm enabled on loopback sockets, and did not loop on partial socket.send() writes.
  4. Attaching to an active session (ManagedSparkSession.builder.sessionId(...).getOrCreate()) issued 3 sequential GetSession RPCs and built 2 clients even when the first response already showed the session was ACTIVE with its endpoint populated.
  5. spark.stop() slept an unconditional 1.0s after confirming the session had already entered TERMINATING, and session creation blocked up to 1.0s waiting for the progress-bar thread's time.sleep(1) to wake.
  6. Importing google.cloud.managed_spark_connect in non-interactive scripts eagerly imported IPython (~100–200ms and 86 transitive modules) whenever optional interactive dependencies were installed in the environment.

End-to-End Benchmark Highlights

Benchmarked using a custom suite based on the instrumentation harness from #210, with top issues identified and fixed by my agent. I do intend to make the benchmark suite public, but it's another few thousand lines of AI-generated code that's not in the trivial path, and needs at least cursory human review by me before sending.

Workflow / Operation Before After Improvement
Interactive schema / catalog / SQL RPCs (df.columns, spark.conf.get, SELECT 1) ~1.5–3.8 s ~0.16–0.45 s 80–90% faster (5–10x)
DataFrame queries & UDFs (range, joins, aggregations, Python/pandas UDFs) ~2.0–8.9 s ~0.27–2.8 s 68–90% faster (2.8–10.3x)
100-iteration query / planning loop ~169 s ~48 s 71% faster (3.5x)
Arrow result download to pandas (toPandas()) ~3.0–18.4 s ~0.55–2.6 s 43–86% faster (1.8–7.2x)
Local WebSocket proxy bulk throughput (35.5 MB) ~8–19 MB/s ~150+ MB/s 8.0–18.8x higher throughput (-87–95% CPU)
First Spark Connect RPC (including WebSocket handshake auth) 4.04 s 1.51 s 63% faster (2.7x)
Attaching to an existing ACTIVE session (getOrCreate()) 3 GetSession RPCs 1 GetSession RPC 61–67% faster (2.5–3.1x)
Stopping a session (spark.stop()) ~2.0–2.4 s ~0.9–1.2 s 46–55% faster (1.8–2.2x)
Spark SQL / runtime error propagation ~2.6–3.4 s ~0.48–0.52 s 81–85% faster (5.1–6.6x)

Key Changes (by Commit)

1. perf(auth): cache default credentials and SessionControllerClient per endpoint

  • Problem: Every control-plane call and WebSocket tunnel connection called google.auth.default() (spawning gcloud subprocesses, ~740–835 ms), creds.refresh() (~80–90 ms HTTP round-trip), and SessionControllerClient() without explicit credentials (spawning gcloud/mTLS helpers and opening an unclosed gRPC TLS channel, ~750–1,680 ms).
  • Solution:
    • get_default_credentials() in client/proxy.py caches (creds, project) under a re-entrant lock (_AUTH_LOCK) keyed by OAuth scopes and auth environment variables (GOOGLE_APPLICATION_CREDENTIALS, GOOGLE_CLOUD_PROJECT, HOME).
    • get_refreshed_credentials() reuses cached credentials and only calls creds.refresh() when not creds.valid or not creds.token (honoring google-auth's 5-minute clock-skew margin), clearing the cache on refresh error.
    • _create_session_controller_client() in session.py caches SessionControllerClient instances in _CLIENT_CACHE keyed by normalized endpoint/auth options and passes cached default credentials so client initialization skips redundant subprocesses.
    • Automatically bypasses caching when google.auth.default or SessionControllerClient is monkeypatched in unit tests, and provides clear_dataproc_caches() for explicit resets.

2. perf(guard): check session liveness lazily on RPC error and skip ReleaseExecute

  • Problem: ProxiedChannel executed a synchronous Dataproc GetSession RPC before every unary and streaming Spark Connect call—including background ReleaseExecute cleanup RPCs (which represent >90% of all gRPC calls)—adding ~100–350 ms of control-plane latency (and >1,200 ms before client caching) to every ~50 ms Spark operation and contending on the GIL during Arrow decoding. Furthermore, transient control-plane 503/504/429 errors caused get_active_s8s_session_response() to return None and falsely abort healthy sessions.
  • Solution:
    • Exempts background /ReleaseExecute and /ReleaseSession cleanup RPCs from guard wrapping in ProxiedChannel._wrap_method().
    • Uses a _GUARD_MODE ContextVar (pre_rpc vs. on_error) between _GuardedMultiCallable / _GuardedStream (client/core.py) and make_is_active_callback() (session.py). On the happy path, pre_rpc dispatches the Spark Connect RPC with zero GetSession calls (unless the session is already known dead in _DEAD_SESSIONS or an optional MANAGED_SPARK_CONNECT_LIVENESS_TTL_SECONDS is configured).
    • When a call or stream raises an exception that _should_check_liveness_on_error() classifies as a possible session termination (skipping client cancellations, invalid arguments, and rich gRPC errors carrying grpc-status-details-bin Spark exceptions from a live server), _run_error_guard() checks GetSession in on_error mode. If the session is no longer active, it records the session in _DEAD_SESSIONS and raises RuntimeError("Session not active. Please create a new session") before PySpark's RetryPolicy can enter a ~60s retry loop; if the session is still ACTIVE, the original grpc.RpcError is re-raised unmodified.
    • Hardens get_active_s8s_session_response() to fall back to _LAST_KNOWN_ACTIVE for up to 60s on transient 503/504/429/500 control-plane errors.

3. perf(proxy): use 64KiB recv, disable deflate, set TCP_NODELAY, and fix partial send

  • Problem: ManagedSparkSessionProxy (client/proxy.py) read from sockets with recv(1024) (fragmenting a 4 MiB payload into ~4,096 WebSocket frames), left WebSocket permessage-deflate enabled (burning single-threaded Python zlib CPU on Arrow/protobuf data), left Nagle's algorithm enabled on accepted loopback sockets (~44 ms delayed-ACK stalls), and called to_sock.send(bs) only once without looping (risking silent HTTP/2 frame truncation on partial kernel socket writes).
  • Solution:
    • Increases RECV_BUFFER_SIZE to 65536 (matching the 64 KiB HTTP/2 window).
    • Passes compression=None and max_size=MAX_WS_MESSAGE_SIZE (64 MiB) in connect_tcp_bridge.
    • Sets socket.TCP_NODELAY on accepted loopback connections in forward_connection and shuts down the listener socket cleanly in ManagedSparkSessionProxy.stop().
    • Loops over remaining byte slices in forward_bytes on partial to_sock.send() calls, resetting the timeout counter on forward progress and raising ConnectionError if send() returns 0.

4. perf(session): avoid redundant GetSession RPCs when attaching to an active session

  • Problem: ManagedSparkSession.builder.sessionId(...).getOrCreate() made 3 sequential GetSession RPCs (_handle_custom_session_id -> _get_exiting_active_session -> _wait_for_session_available) and constructed 2 SessionControllerClient instances even when the session was already ACTIVE with its Spark Connect endpoint present on the first call.
  • Solution:
    • Stashes the Session proto from _handle_custom_session_id in self._custom_session_response when state == Session.State.ACTIVE (cleaned up on entry and in finally: of getOrCreate()), marks the session active in the liveness tracker, and cleanly detaches stale local client state (stop(terminate=False)) when switching session IDs or UUIDs.
    • Consumes self._custom_session_response in _get_exiting_active_session and calls __create_spark_connect_session_from_s8s directly when "Spark Connect Server" is already present in session_response.runtime_info.endpoints, falling back to _wait_for_session_available only if the endpoint is not yet populated.
    • Reuses self.session_controller_client in Builder.__create.

5. perf(polling): skip post-check sleep in terminate_s8s_session and wake pbar on event

  • Problem: terminate_s8s_session() slept an unconditional 1.0s at the end of its while loop body even though the first get_session() after terminate_session() already observed TERMINATING; create_session_pbar used uninterruptible time.sleep(1) calls that blocked join() for up to 1.0s after session creation finished; and operation.result() polled at a flat 5.0s interval.
  • Solution:
    • Only sleeps in terminate_s8s_session() when state has not yet reached TERMINATING, TERMINATED, or FAILED (with a max_polls = 180 safety bound).
    • Replaces time.sleep(1) in create_session_pbar with stop_create_session_pbar_event.wait(1) so setting the stop event wakes the progress-bar thread immediately.
    • Updates the LRO polling retry.Retry policy in Builder.__create to start at initial=1.0 with multiplier=1.5 up to maximum=5.0.

6. perf(import): skip IPython import outside notebooks and defer packaging.requirements

  • Problem: Importing google.cloud.managed_spark_connect unconditionally executed from IPython import get_ipython and imported _ipython whenever traitlets was installed, loading IPython and 86 transitive modules (~100–200ms) even in non-interactive CLI scripts and batch jobs; pypi_artifacts.py also eagerly imported packaging.requirements.Requirement at module import time.
  • Solution:
    • Checks sys.modules.get("IPython") in _in_ipython() (__init__.py) and _init_extras() (_ipython.py) before invoking get_ipython(), avoiding cold-importing IPython outside active interactive kernels while preserving automatic magic/display registration inside Jupyter and IPython sessions.
    • Defers from packaging.requirements import Requirement in pypi_artifacts.py to PyPiArtifacts.__try_parsing_package().

Testing

  • Added comprehensive unit test coverage across tests/unit/test_dataproc.py, tests/unit/test_guard.py, tests/unit/test_proxy.py, tests/unit/test_session.py, tests/unit/test_polling.py, tests/unit/test_init.py, and tests/unit/test_core.py.
  • Benchmarked and verified end-to-end against live Dataproc Serverless Spark Connect sessions.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces significant improvements to session liveness management and credential handling in the managed_spark_connect client. Key changes include the implementation of a liveness guard with TTL caching to prevent premature session termination, optimized credential caching, and refined socket handling for improved performance. The review feedback highlights a potential issue where disabling the fallback cache during error handling could lead to false-positive session terminations, suggesting that the fallback cache should remain enabled in on_error mode to allow for safer retry behavior.

@dborowitz
dborowitz force-pushed the dborowitz-optimization branch from 712aab3 to 2c55003 Compare October 20, 2026 04:42
@dborowitz

Copy link
Copy Markdown
Contributor Author

CI didn't run because it's configured to only run for PRs based on main; not surprising since the CI implementation predates stacked PRs as a feature. I'm folding a fix for that into #207.

… endpoint

Previously, every control-plane call and WebSocket tunnel connection
fetched Application Default Credentials (`google.auth.default()`),
refreshed OAuth2 tokens (`creds.refresh()`), and constructed a fresh
Dataproc `SessionControllerClient` from scratch. In developer and
workstation environments using user credentials, each uncached
`google.auth.default()` call spawns `gcloud` subprocesses (~740-835 ms),
each `SessionControllerClient()` construction without explicit
credentials spawns `gcloud` and mTLS helper subprocesses while opening
an unclosed gRPC TLS channel (~750-1,680 ms), and each unconditional
`creds.refresh()` adds an ~80-90 ms HTTP round-trip. Even without
per-RPC liveness polling, a single session lifecycle still loads
credentials and instantiates `SessionControllerClient` multiple times
across `session.py`, `client/core.py`, and `client/proxy.py` when
creating or attaching to a session, opening the WebSocket proxy on the
first RPC, and polling termination in `spark.stop()`.

To share credentials across the WebSocket proxy, direct gRPC channels,
and Dataproc control-plane clients without circular imports, credential
caching lives in `client/proxy.py`. `get_default_credentials()` caches
`(creds, project)` under a re-entrant lock (`_AUTH_LOCK`) keyed by OAuth
scopes and auth environment variables (`GOOGLE_APPLICATION_CREDENTIALS`,
`GOOGLE_CLOUD_PROJECT`, `HOME`). `get_refreshed_credentials()` reuses
those cached credentials and skips `creds.refresh()` under `_AUTH_LOCK`
while `creds.valid` is `True` (which enforces the `google-auth` built-in
5-minute clock-skew margin against `creds.expiry`) and `creds.token` is
populated, clearing `_CREDENTIALS_CACHE` on refresh failure so broken
credentials are never pinned. In `client/core.py`,
`ManagedSparkChannelBuilder._direct_channel` also reuses
`get_default_credentials()` and builds composite SSL and
`AuthMetadataPlugin` call credentials with a default port of 443.

In `session.py`, `_create_session_controller_client()` caches
`SessionControllerClient` instances in `_CLIENT_CACHE` keyed by a
normalized tuple of endpoint and auth options (`api_endpoint`,
`quota_project_id`, `universe_domain`, `api_audience`,
`credentials_file`, `api_key`, `scopes`) and environment variables. When
standard default credentials are used, it passes the cached `creds` from
`get_default_credentials()` into `SessionControllerClient` so
instantiation skips redundant `google.auth.default()` subprocesses and
the gRPC auth metadata plugin refreshes expiring tokens in-flight on the
open channel; when custom credential options or client certificate
callbacks are supplied in `client_options`, it avoids passing default
credentials so `google-api-core` does not raise
`DuplicateCredentialArgs`. Both caches automatically bypass caching when
`google.auth.default` or `SessionControllerClient` is monkeypatched in
unit tests, and `clear_dataproc_caches()` provides explicit cache reset.

Simpler alternatives—such as caching the client on the `Builder`
instance or threading a client parameter through internal helpers—are
insufficient because `SparkSession.builder` constructs a new `Builder`
on every property access, while session reuse checks, background
WebSocket proxy reconnect threads, liveness checks, and
`terminate_s8s_session()` in `spark.stop()` all run outside the original
`Builder` instance. Process-wide caching keyed by effective endpoint and
auth configuration eliminates duplicate subprocesses and gRPC channel
leaks across all lifecycle entry points without changing public APIs.

Benchmark summary: Reduces warm credential lookup and client acquisition
from ~745-827 ms (3-6 subprocesses) to under 2 us (0 subprocesses),
cutting first Spark Connect RPC latency (including WebSocket handshake
auth) by 63% (from 4.04 s to 1.51 s, 2.7x faster) and eliminating
~705-813 ms of client initialization overhead during `spark.stop()`.
…aseExecute

Previously, `ProxiedChannel` wrapped every Spark Connect gRPC method in
`_GuardedMultiCallable`, which executed a synchronous Dataproc
control-plane `GetSession` RPC before every unary and streaming call.
Because a single DataFrame operation issues 3-7 foreground Spark Connect
RPCs plus 8-100+ background `ReleaseExecute` cleanup RPCs (which account
for over 90% of all gRPC calls), this pre-RPC guard added ~100-350 ms of
control-plane RPC latency (and over 1,200 ms prior to client caching) to
every ~50 ms Spark operation, while concurrent background
`ReleaseExecute` guard threads contended on the Python GIL and slowed
Arrow result decoding by more than 2x. In addition,
`get_active_s8s_session_response()` caught all exceptions and returned
`None`, causing transient control-plane 503/504/429 blips to abort
healthy Spark sessions with `RuntimeError(Session not active)`.

In `client/core.py`, `ProxiedChannel._wrap_method()` now exempts
background `/ReleaseExecute` and `/ReleaseSession` cleanup calls from
guard wrapping altogether. For foreground RPCs, `_GuardedMultiCallable`
and `_GuardedStream` coordinate with the guard callback via a
`_GUARD_MODE` `ContextVar` (`pre_rpc` vs. `on_error`). On the happy
path, `_run_pre_guard()` executes in `pre_rpc` mode with zero
`GetSession` RPCs and dispatches the Spark Connect call directly,
wrapping streaming responses (`ExecutePlan`) in `_GuardedStream` so
mid-stream failures are also intercepted. Only when a call raises an
exception that `_should_check_liveness_on_error()` classifies as a
possible session termination—skipping client cancellations, invalid
argument errors, and rich gRPC errors carrying `grpc-status-details-bin`
Spark SQL/execution exceptions from a live server—does
`_run_error_guard()` invoke the guard in `on_error` mode.

In `session.py`, `make_is_active_callback()` tracks session state across
`_DEAD_SESSIONS`, `_LAST_KNOWN_ACTIVE`, and `_LIVENESS_CACHE`. When an
`on_error` check confirms via `GetSession` that a session is no longer
active, the session is recorded in `_DEAD_SESSIONS` and `ProxiedChannel`
raises `RuntimeError(Session not active. Please create a new session)`
before the PySpark `RetryPolicy` can enter a ~60 s retry backoff loop;
all subsequent RPCs on that session fast-fail in `pre_rpc` mode without
network calls, whereas if the session is still `ACTIVE`, the original
`grpc.RpcError` is re-raised unmodified. To harden control-plane checks,
`get_active_s8s_session_response()` falls back to `_LAST_KNOWN_ACTIVE`
for up to 60 s on transient `503`/`504`/`429`/`500` control-plane errors
instead of falsely marking the session dead, and supports an optional
`MANAGED_SPARK_CONNECT_LIVENESS_TTL_SECONDS` environment variable if
periodic pre-RPC TTL polling is ever desired.

Checking liveness lazily on RPC error was chosen over pre-RPC TTL
caching, background polling threads, or removing the guard entirely.
Pre-RPC TTL caching and background polling still require on-error checks
when a session terminates mid-TTL window or mid-stream, while
introducing periodic tail-latency spikes on TTL expiry or background
thread lifecycle complexity. Conversely, removing the guard altogether
loses actionable session-termination diagnostics because a terminated
session drops the WebSocket proxy with a generic `UNAVAILABLE` error
that causes PySpark to retry repeatedly before surfacing an opaque
transport failure.

Benchmark summary: Eliminates 100% of control-plane `GetSession` RPCs on
the happy path, reducing end-to-end latency by 80-90% (5.0-10.0x faster)
on interactive schema, config, and SQL operations, 68-90% (2.8-10.3x
faster) on DataFrame queries and UDFs, 71% (3.5x faster) on a
100-iteration query loop, and 81-85% (5.1-6.6x faster) when propagating
Spark SQL analysis and runtime errors.
…x partial send

In the default proxy mode, `ManagedSparkSessionProxy` (`client/proxy.py`)
bridges local gRPC/HTTP2 traffic over a WebSocket tunnel, placing it in
the data path of every Spark Connect request and response. Previously,
four compounding issues in this bridge severely throttled throughput,
inflated small-RPC latency, and risked stream corruption: `forward_bytes`
read from the socket in 1,024-byte chunks (`recv(1024)`), turning a
4 MiB upload into ~4,096 separate WebSocket frames that stalled behind
synchronous round-trips in the remote proxy; `websockets.sync.client`
left `permessage-deflate` enabled by default, burning single-threaded
Python zlib CPU on already-compressed Arrow and protobuf payloads;
accepted loopback sockets left the Nagle algorithm enabled, triggering
~44 ms delayed-ACK stalls on multi-chunk messages; and `forward_bytes`
called `to_sock.send(bs)` only once without looping, which can silently
truncate HTTP/2 frames whenever the kernel TCP send buffer partially
accepts a large inbound WebSocket frame.

To fix these bottlenecks and the partial-write bug in `client/proxy.py`,
`RECV_BUFFER_SIZE` is increased to `65536` (matching the default 64 KiB
HTTP/2 window), and `connect_tcp_bridge` passes `compression=None` and
`max_size=MAX_WS_MESSAGE_SIZE` (`64 * 1024 * 1024`, a 64 MiB cap that
supports large Arrow batches without unbounded memory growth). When
accepting local connections in `forward_connection` (and shutting down
the listener cleanly in `ManagedSparkSessionProxy.stop`), the proxy sets
`TCP_NODELAY` on each loopback socket. Finally, `bridged_socket.send`
now returns `len(msg_bytes)` and `forward_bytes` loops over remaining
byte slices on partial `to_sock.send()` calls—resetting the timeout
retry counter whenever forward progress is made, raising
`ConnectionError` if `send()` returns `0` on a closed peer, and closing
both sockets on unrecoverable errors.

Tuning the synchronous socket bridge and fixing its partial-write loop
was chosen over replacing `websockets.sync` with an `asyncio` proxy or
switching entirely to direct gRPC. While direct gRPC is still blocked in
environments where upstream HTTP/2 proxies strip trailing gRPC error
details, these targeted fixes eliminate almost all local framing and
compression overhead with minimal invasive changes while preserving the
existing threaded proxy architecture.

Benchmark summary:
- Local loopback WebSocket proxy throughput increases 8.0x-18.8x on bulk
  35.5 MB transfers (reducing process CPU time by 87-95%), and 8 KiB
  ping-pong RPC latency drops by 98% (from 44.0 ms to 0.70 ms).
- End-to-end live Dataproc Spark Connect latency improves by 17-30% on
  small interactive SQL/catalog/schema RPCs and pandas UDFs (proxy-only),
  and by 43-86% (1.8x-7.2x faster) when downloading Arrow result batches
  into pandas alongside the liveness guard fix.
…ctive session

Attaching to an already-running Dataproc session via
`ManagedSparkSession.builder.sessionId(...).getOrCreate()` previously
made three sequential `GetSession` control-plane RPCs (~250-300 ms each)
and constructed two separate `SessionControllerClient` instances before
sending the first Spark Connect RPC, even when the session was already
`ACTIVE` with its Spark Connect endpoint populated on the very first
call. Specifically, `_handle_custom_session_id` fetched the session to
validate its state and discarded the response;
`_get_exiting_active_session` immediately instantiated a new client to
fetch the same session a second time; and `_wait_for_session_available`
unconditionally polled `GetSession` a third time to read the endpoint
map.

To eliminate the redundant round-trips in `session.py`,
`_handle_custom_session_id` now stashes the `Session` proto from its
initial `GetSession` call in `self._custom_session_response` whenever
`state == Session.State.ACTIVE` (clearing it on entry and in a `finally`
block in `getOrCreate()` so stale responses never leak across builds)
and calls `mark_s8s_session_active` so the liveness tracker knows the
session is live without an extra check. When switching between distinct
session IDs or UUIDs, `_handle_custom_session_id` also cleanly detaches
any previous local client wrapper (`stop(terminate=False)`). Downstream,
`_get_exiting_active_session` consumes `self._custom_session_response`
instead of issuing a second `GetSession` RPC, and checks whether
`Spark Connect Server` is already present in
`session_response.runtime_info.endpoints`—calling
`__create_spark_connect_session_from_s8s` directly and only falling back
to the `_wait_for_session_available` polling loop if the endpoint map is
not yet populated. In addition, `Builder.__create` now reuses
`self.session_controller_client` instead of constructing a duplicate
client when creating a new session.

Passing the already-fetched `Session` response across the existing
two-stage validation and attachment methods was chosen over refactoring
the entire session builder call graph. This preserves all existing
validation, telemetry spans, and fallback polling behavior when a target
session is still `CREATING` or waiting for its endpoint to register,
while cutting steady-state attachment down to a single `GetSession` RPC.

Benchmark summary:
- Cuts `GetSession` control-plane RPCs when attaching to an active
  session from 3 to 1 (-67%) and `SessionControllerClient` creations
  from 2 to 1 (-50%).
- Reduces in-process `getOrCreate()` control-plane attachment latency by
  65% (from 262 ms to 91 ms at 85 ms RTT) and live end-to-end
  existing-session attachment time by 61-67% (2.5x-3.1x faster).
…e pbar on event

Session lifecycle operations previously incurred unnecessary dead wait
time from three fixed-sleep and polling patterns. First, when stopping a
session, `terminate_s8s_session()` polled `get_session()` in a `while`
loop until the session entered `TERMINATING`, `TERMINATED`, or `FAILED`,
but executed an unconditional `time.sleep(1)` at the bottom of the loop
body before re-evaluating the loop condition. Because Dataproc
transitions sessions to `TERMINATING` almost immediately during
`terminate_session()`, the first `get_session()` call already observed
`TERMINATING`, yet every `stop()` call still slept a full 1.0s before
returning (and had no upper bound if a session never transitioned).
Second, during session creation, the background progress-bar thread
(`create_session_pbar`) used uninterruptible `time.sleep(1)` calls,
blocking `create_session_pbar_thread.join()` for up to 1.0s (500ms
median) after the session was already ready, while `operation.result()`
polled the creation operation at a fixed 5.0s interval (`initial=5.0`,
`multiplier=1.0`), adding up to 5.0s of polling quantization delay.

In `terminate_s8s_session()`, the loop now checks `state not in
terminal_states` with a `max_polls = 180` safety bound and only calls
`time.sleep(1)` after `get_session()` if the session has not yet reached
`TERMINATING`, `TERMINATED`, or `FAILED` and further poll iterations
remain. In `Builder.__create()`, `create_session_pbar` replaces
`time.sleep(1)` with `stop_create_session_pbar_event.wait(1)` (and
`stop_create_session_pbar_event.wait()` on the final progress tick) so
that setting `stop_create_session_pbar_event` upon session creation
completion wakes and joins the progress-bar thread immediately. Finally,
the `retry.Retry` policy in `Builder.__create()` is updated to start at
`initial=1.0` with `multiplier=1.5` up to `maximum=5.0`.

Checking `get_session()` once immediately after `terminate_session()`
and sleeping only when another poll is actually required was chosen over
removing the post-termination check altogether because it preserves the
guarantee that the session has transitioned out of `ACTIVE` before
`stop()` returns, while eliminating the 1.0s post-check penalty in the
common case and bounding worst-case polling at 180 attempts. Similarly,
replacing fixed 5.0s polling with exponential backoff (`1.0s` to `5.0s`)
and event-driven progress-bar wakeup detects newly ready sessions
several seconds earlier without flooding the Dataproc control plane
during longer cluster startups.

Benchmark summary:
- Stopping a session end-to-end is 46-55% faster (1.8-2.2x speedup,
  saving ~1.0s per `stop()` call), with the termination state-check step
  dropping by 88% (from ~1,112ms to ~137ms).
- Progress-bar thread teardown at the end of session creation drops from
  ~500ms median (up to 1,000ms) to ~0.5ms, and initial readiness polling
  starts at 1.0s instead of 5.0s.
…ng.requirements

Importing `google.cloud.managed_spark_connect` in non-interactive CLI
scripts, batch jobs, or services previously triggered avoidable module
loading when optional dependencies were installed in the environment. At
package import time, `__init__.py` unconditionally called `_in_ipython()`
(which executed `from IPython import get_ipython`) and imported
`_ipython._init_extras()` whenever `traitlets` was installed, pulling in
`IPython` and dozens of its transitive submodules (~100-200ms of isolated
import overhead) even when running in a standard Python script outside
any notebook or IPython shell. In addition, `pypi_artifacts.py` imported
`packaging.requirements.Requirement` eagerly at module top level (loaded
transitively via `session.py`), pulling in the `packaging` parser and
tokenizer submodules even when PyPI package artifacts were never used.

To avoid loading interactive notebook machinery outside an active
IPython session, `_in_ipython()` in `__init__.py` and `_init_extras()`
in `_ipython.py` now look up `sys.modules.get(IPython)` before calling
`get_ipython()`, and `__init__.py` gates importing `_ipython` and
calling `_init_extras()` on `_in_ipython() and
importlib.util.find_spec(traitlets) is not None`. Since an active
Jupyter or IPython kernel always has `IPython` present in `sys.modules`
before user code imports the library, `sys.modules.get(IPython)`
accurately detects active interactive shells without triggering a cold
import of `IPython`. In `pypi_artifacts.py`, the top-level
`packaging.requirements.Requirement` import is placed under `if
TYPE_CHECKING:` for static type checkers and deferred to runtime inside
`PyPiArtifacts.__try_parsing_package()`.

Inspecting `sys.modules.get(IPython)` before invoking `get_ipython()`
was chosen over removing automatic interactive initialization or relying
solely on `importlib.util.find_spec(traitlets)` because virtual
environments frequently have `IPython` and `traitlets` installed even
when executing non-interactive batch scripts. Checking `sys.modules`
completely eliminates the `IPython` and `_ipython` import penalty for
standard Python scripts while still automatically registering magics and
display integrations whenever the package is imported inside an active
IPython or Jupyter notebook session.

Benchmark summary:
- Eliminates 86 transitive module imports on non-notebook startup,
  reducing isolated interactive-check overhead by 50% (~207ms to ~103ms)
  and `pypi_artifacts` module import time by 97% (~17.1ms to ~0.4ms).
@dborowitz
dborowitz force-pushed the dborowitz-optimization branch from d57d1d8 to 0d5298c Compare October 27, 2026 23:51
@dborowitz
dborowitz merged commit 6dc44d5 into main Oct 28, 2026
20 checks passed
@dborowitz
dborowitz deleted the dborowitz-optimization branch October 28, 2026 21:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants