Skip to content

fix(hygiene): eliminate gRPC fork/ReleaseExecute hazards and clean up logging and notebook output noise - #217

Open
dborowitz wants to merge 4 commits into
dborowitz-resiliencefrom
dborowitz-hygiene
Open

dborowitz wants to merge 4 commits into
dborowitz-resiliencefrom
dborowitz-hygiene

Conversation

@dborowitz

Copy link
Copy Markdown
Contributor

Note

Stacked on #216 (dborowitz-resilience), #211 (dborowitz-optimization), and #210 (dborowitz-timing).

Summary

Eliminates C-core gRPC fork/threading hazards (I0000 ... fork_posix.cc:71] Other threads are currently calling into gRPC, skipping fork() handlers and post-query background ReleaseExecute storms) and cleans up log, stderr, and notebook cell-output noise across google-cloud-spark-connect, with sensible defaults and explicit Builder / environment-variable controls.

1. Proper gRPC Lifecycle & Fork-Safety Fixes

  • Why fork_posix.cc:71 happened:
    1. Post-query ReleaseExecute storm (reattach.py): PySpark's ExecutePlanResponseReattachableIterator submits a background ReleaseExecute(ReleaseUntil) RPC to a shared ThreadPoolExecutor for every streamed Arrow batch plus ReleaseAll on result_complete, while returning to the caller immediately as soon as result_complete arrives. On a 500-batch df.toPandas() call, 500+ background ReleaseExecute RPCs continued firing into C-core gRPC for up to 13 seconds after toPandas() returned, colliding with any subsequent subprocess (!shell, gcloud, %pip, matplotlib) in the same or next notebook cell.
    2. Lazy credential/client subprocesses during in-flight gRPC calls: When connect_tcp_bridge() initialized credentials lazily on the proxy thread while the gRPC thread was already inside C-core waiting on the loopback socket, any gcloud or /opt/google/endpoint-verification/bin/apihelper subprocess forked while a thread held an active gRPC ExecCtx.
    3. Lingering proxy sockets on teardown: ManagedSparkSessionProxy.stop() only closed the listener socket without shutting down active client/bridged sockets or joining forwarding threads.
  • How this PR fixes it:
    • Coalesces concurrent ReleaseUntil RPCs and quiesces on completion (client/reattach_patch.py): Allows at most 1 in-flight ReleaseExecute per iterator at a time, coalescing intermediate until_response_id updates to the latest 1-batch-slack ID, aborting redundant ReleaseUntils once _result_complete or _release_all is reached, and draining in-flight release futures (bounded by MANAGED_SPARK_CONNECT_RELEASE_DRAIN_TIMEOUT_S, default 2.0s) before _execute_and_fetch / _execute_and_fetch_as_iterator / stop() returns. In live Dataproc benchmarks (1,000,000 rows, 514 Arrow batches), this reduced ReleaseExecute RPCs from 517 to 3–5 (99%+ reduction) with 0 background gRPC threads remaining after toPandas() returns.
    • Pre-warms credentials and SessionControllerClient before starting the proxy or gRPC channel (session.py, client/core.py): Runs get_refreshed_credentials() and _create_session_controller_client() on the calling thread before spinning up ManagedSparkSessionProxy or the C-core gRPC channel.
    • Cleanly shuts down active proxy sockets and threads (client/proxy.py, client/core.py, session.py): Tracks active client sockets, bridged WebSocket wrappers, and forwarding threads in ManagedSparkSessionProxy and shuts them down (SHUT_RDWR) and joins threads on stop() / ProxiedChannel.close().
    • Defaults GRPC_VERBOSITY to ERROR (__init__.py): Calls os.environ.setdefault("GRPC_VERBOSITY", "ERROR") before any transitive grpc import so benign C-core I0000 info logs from fork()+exec() subprocesses are suppressed while preserving genuine ERROR logs and respecting user-set GRPC_VERBOSITY.

2. Log, Warning, and Notebook Output Hygiene

  • No more root-logger hijacking: Removes logging.basicConfig(level=logging.INFO) from session.py (which previously attached a root StreamHandler(sys.stderr) at INFO for the entire Python process at import time) and attaches a logging.NullHandler() to logging.getLogger("google.cloud.managed_spark_connect") in __init__.py. Setting MANAGED_SPARK_CONNECT_LOG_LEVEL=<LEVEL> configures only the google.cloud.managed_spark_connect logger hierarchy.
  • No more non-interactive tqdm threads or stray print()s:
    • Gates the session-creation tqdm progress-bar thread (create_session_pbar) and its trailing newline on interactivity (environment.is_interactive() or MANAGED_SPARK_CONNECT_FORCE_PROGRESS_BAR=1).
    • Demotes bare print(f"... Managed Spark Session deleted: {e}") calls in get_active_s8s_session_response() (which fired on routine NotFound checks when creating a custom sessionId) and duplicate logger.info calls in _get_session_by_id() to logger.debug(...).
    • Routes first-time session attach messages through _output_element_or_message() and keeps repeated in-process getOrCreate() singleton returns silent.
  • Clean per-query notebook output & new Builder / env knobs:
    • Defaults execution progress bar leave to False so transient df.columns / df.count() bars clean up after completion instead of stacking in notebook cells.
    • Adds Builder.showOperationLinks(bool) (MANAGED_SPARK_CONNECT_SHOW_OPERATION_LINKS), Builder.showProgressBar(bool) (MANAGED_SPARK_CONNECT_PROGRESS_BAR), and Builder.progressBarLeave(bool) (MANAGED_SPARK_CONNECT_PROGRESS_BAR_LEAVE).
  • Additional noise & warning fixes:
    • Normalizes three-part runtime_version strings (e.g. "2.3.10" -> "2.3") before looking up in RUNTIME_PYTHON_MAP in _check_python_version_compatibility() so patch-level runtime versions do not emit false "Unrecognized Spark runtime version" warnings.
    • Silences PySpark 4.0's SQLQueryContextLogger / DataFrameQueryContextLogger raw JSON stderr dumps on query exceptions unless SPARK_CONNECT_LOG_LEVEL or MANAGED_SPARK_CONNECT_LOG_LEVEL is set.
    • Filters PySpark 4.0's internal Pandas4Warning: The copy keyword is deprecated emitted inside toPandas().
    • Demotes unpinned-package recommendations in pypi_artifacts.py and ProxiedChannel._check_active() inactive-session log lines (right before raising RuntimeError) to DEBUG, adds stacklevel=2 to the deprecated package warning in __init__.py, and replaces assert all(...) with a clean early return in _display_operation_link().

Commits in this PR

  1. fix(grpc): set default GRPC_VERBOSITY=ERROR, pre-warm credentials, and close proxy sockets cleanly
  2. fix(reattach): coalesce concurrent ReleaseUntil RPCs and quiesce release pool on completion
  3. fix(logging): remove root basicConfig and gate session/query output on interactivity and builder knobs

Testing

  • Added unit and subprocess regression tests in tests/unit/test_grpc_fork_noise.py, tests/unit/test_reattach_patch.py, and tests/unit/test_logging_hygiene.py.
  • Verified live against Dataproc Serverless Spark Connect sessions across both the 96-trial resilience suite and subprocess/notebook hygiene probes (0 fork_posix.cc lines, 0 leaked tqdm bars, 0 stray stdout/stderr lines in non-interactive mode).

@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 several enhancements to the managed Spark Connect client, including credential pre-warming to avoid gRPC fork-safety issues, coalesced ReleaseUntil management to prevent background thread build-up, and improved proxy socket and thread lifecycle management to ensure clean teardown. Feedback on the changes highlights a concurrency race condition in _ensure_iterator_release_state where attributes may be accessed before initialization, a potential session leak if self.client.close() raises an exception during session stop, and cross-platform test failures on Windows due to Unix-specific commands and os.fork() usage.

Comment thread tests/unit/test_grpc_fork_noise.py Resolved
Comment thread tests/unit/test_grpc_fork_noise.py Resolved
@dborowitz
dborowitz force-pushed the dborowitz-hygiene branch 2 times, most recently from 3b40169 to e4a6533 Compare October 26, 2026 19:23
@dborowitz
dborowitz added this pull request to stack #220 October 26, 2026 19:54
@dborowitz
dborowitz force-pushed the dborowitz-hygiene branch 2 times, most recently from b08f28d to 13b4a1e Compare October 28, 2026 21:33
@dborowitz
dborowitz force-pushed the dborowitz-hygiene branch 5 times, most recently from cb92e8b to f9c360e Compare October 30, 2026 18:23
…d close proxy sockets cleanly

- Set GRPC_VERBOSITY=ERROR (unless already set) at the top of
  google/cloud/managed_spark_connect/__init__.py when grpc has not been
  imported yet, since gRPC only reads it on import. User-configured
  GRPC_VERBOSITY and GRPC_ENABLE_FORK_SUPPORT are left untouched. Documented
  in the README.
- Pre-warm refreshed ADC credentials on the calling thread in
  ProxiedChannel.__init__(), with the session's recorder, before starting
  ManagedSparkSessionProxy or the gRPC channel, so subprocess credential
  helpers (gcloud) do not fork while a gRPC thread holds an active C-core
  ExecCtx.
- Track active client sockets, backend bridged sockets, and worker threads in
  ManagedSparkSessionProxy; stop() closes all of them and joins the threads
  against one shared deadline. A backend that connects after stop() is
  closed and its connection abandoned. Backend sockets are closed with the
  new bridged_socket.close_abrupt(), which doesn't wait for a WebSocket
  Close handshake. A failed connection logs a WARNING once, then DEBUG
  until a connection succeeds again.
- Add ManagedSparkSession._close_client(), the one teardown path for stop():
  close the StreamingQueryManager and the client (logging a failure as a
  warning), unregister PySpark's SparkConnectClient._cleanup_ml from atexit,
  and remove the client hooks, method wrappers and progress handler, so
  closed sessions and gRPC channels (plus their C-core eventpoll/eventfd FDs)
  can be garbage-collected instead of remaining pinned for the process
  lifetime.
- Skip pyspark's SparkSession.__del__ once _close_client() has run. Otherwise
  a stopped session collected by the GC on a ReleaseExecute pool thread shut
  down pyspark's shared release pool from inside it, leaving the pool
  unusable for every later query in the process.
- Ensure SPARK_CONNECT_MODE_ENABLED=1 is set in ManagedSparkSession.__init__()
  and Builder.__connect() in addition to Builder.__create().
- Add pre-warm, teardown, and GC regression tests in
  tests/unit/test_grpc_fork_noise.py, and a tests/unit/conftest.py that keeps
  unit tests from looking up real credentials.
pyspark sends one ReleaseExecute(until) per response, each submitted to its
shared release thread pool. A fast result stream behind a slow round trip
through the proxy queues up hundreds of RPCs that are stale by the time
they run.

install_release_coalescing(client), called for every ManagedSparkSession,
keeps at most one ReleaseExecute(until) in flight per iterator: while one
is running, later responses only move the pending release point, and the
next RPC sends the latest one. Releases stop once the result is complete,
where pyspark's own ReleaseAll takes over. The RPC is built and retried the
way pyspark's _release_until does it, and failures are still logged as
warnings. A shut-down release pool skips the release instead of failing
the caller's iteration.

The patch replaces ExecutePlanResponseReattachableIterator._release_until
once per process but only changes iterators whose stub belongs to a client
it was installed for; plain pyspark clients in the same process keep
pyspark's behaviour. ReleaseAll is untouched, so an early break or a
KeyboardInterrupt still interrupts the operation on the server.
…n interactivity and builder knobs

- Remove logging.basicConfig(level=logging.INFO) from session.py and attach a
  NullHandler to the google.cloud.managed_spark_connect package logger at
  import (new _logging module), with optional MANAGED_SPARK_CONNECT_LOG_LEVEL
  configuration that only configures the package logger. If it attaches its
  own StreamHandler, the package logger stops propagating, so records are
  not printed twice once the application configures the root logger.
- Gate the session-creation tqdm progress bar thread on interactive/forced
  progress bar settings so non-interactive sessions spawn no thread and emit no
  trailing newline.
- Demote 'Managed Spark Session deleted' print() statements in
  get_active_s8s_session_response() and custom-ID session reuse logger.info()
  calls in _get_session_by_id() to logger.debug().
- Route first-time session attach and inactive-session messages in
  _get_exiting_active_session() through _output_element_or_message() (with
  the session ID HTML-escaped) and keep repeated getOrCreate() calls
  returning the in-process singleton silent.
- Add MANAGED_SPARK_CONNECT_SHOW_OPERATION_LINKS / Builder.showOperationLinks(),
  MANAGED_SPARK_CONNECT_PROGRESS_BAR / Builder.showProgressBar(), and
  MANAGED_SPARK_CONNECT_PROGRESS_BAR_LEAVE / Builder.progressBarLeave() controls,
  defaulting execution progress bar leave=False. Links and bars still only
  appear in interactive environments; the settings can hide them there. They
  apply to a newly created or attached session, and are documented in the
  README.
- Normalize runtime_version to major.minor before looking up in
  RUNTIME_PYTHON_MAP in _check_python_version_compatibility().
- Demote unpinned package recommendation in pypi_artifacts.py to DEBUG, demote
  ProxiedChannel._check_active() inactive-session log to DEBUG before raising
  RuntimeError, add stacklevel=2 to deprecated package warning in __init__.py,
  and replace assert all(...) with early return in _display_operation_link().
- When a ManagedSparkSession is created, silence PySpark's
  SQLQueryContextLogger and DataFrameQueryContextLogger stderr JSON dumps
  (for the whole process) unless SPARK_CONNECT_LOG_LEVEL or
  MANAGED_SPARK_CONNECT_LOG_LEVEL is set.
- Add comprehensive unit tests in tests/unit/test_logging_hygiene.py.
Production code no longer checks whether tests patched it:

- Drop _ORIG_SESSION_CONTROLLER_CLIENT, _resolve_session_controller_client_cls
  and the side_effect check: Dataproc clients always come from
  dataproc_v1.SessionControllerClient and are always cached. Tests patch
  that name.
- Drop the side_effect check in get_default_credentials and the
  isinstance(valid, bool) check in get_refreshed_credentials: credentials
  are refreshed when they are not valid or have no token.
- Drop _ORIG_GET_ACTIVE_S8S_SESSION_RESPONSE and _ORIG_IS_S8S_SESSION_ACTIVE.
  The liveness guard asks the cache, then is_s8s_session_active. The
  cache key no longer includes the client class.
- Drop the _supports_error_guard function attribute: the channel runs the
  liveness guard after a failed RPC for every callback.

ManagedSparkSession.stop() on a session that is not the default one (one
attached directly, or replaced by a later getOrCreate()) used to terminate
the default session's server session and clear its class-level state. It
now only closes its own client. ManagedSparkSession now declares its own
_default_session and getOrCreate() always sets it, so a plain pyspark
session holding pyspark's default can't keep the managed session from
owning, and stop() from terminating, its server session. Likewise,
ManagedSparkSession.getActiveSession() ignores a plain pyspark session in
pyspark's shared per-thread active slot, which getOrCreate() would
otherwise have reused as if it were managed.

The session's own wrappers of _execute, _execute_and_fetch_as_iterator and
_execute_plan_request_with_metadata are installed with install_client_hooks
like the others, so closing the session restores all of them.

Tests: unit tests get fake valid credentials from tests/unit/conftest.py,
which now also holds the shared session_response()/dataproc_mocks fixtures;
the Builder's private _create_spark_connect_session_from_s8s is no longer
name-mangled; a test session that was never stopped is stopped.
@dborowitz

Copy link
Copy Markdown
Contributor Author

/gemini review

@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 several enhancements to the Managed Spark Connect client, including ReleaseExecute RPC coalescing to prevent queuing stale release calls, improved logging hygiene (such as silencing gRPC fork noise and PySpark query context dumps), and customizable display settings for operation links and progress bars. It also refactors session lifecycle management to prevent reference cycles and ensure clean teardown. The review feedback highlights critical compatibility issues with older PySpark versions (such as potential ModuleNotFoundError and AttributeError during cleanup), a thread-safety concern when initializing the coalesced release state, and a potential AttributeError if the release thread pool is uninitialized.

@dborowitz
dborowitz requested a review from tim-u October 30, 2026 21:53
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.

1 participant