Repository navigation
Conversation
There was a problem hiding this comment.
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.
5b19a34 to
db15f06
Compare
3b40169 to
e4a6533
Compare
e4a6533 to
bd3aff4
Compare
bd3aff4 to
1856189
Compare
b08f28d to
13b4a1e
Compare
cb92e8b to
f9c360e
Compare
f9c360e to
5c41714
Compare
…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.
5c41714 to
39faa6b
Compare
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.
39faa6b to
bff2b7d
Compare
|
/gemini review |
There was a problem hiding this comment.
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.
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() handlersand post-query backgroundReleaseExecutestorms) and cleans up log,stderr, and notebook cell-output noise acrossgoogle-cloud-spark-connect, with sensible defaults and explicit Builder / environment-variable controls.1. Proper gRPC Lifecycle & Fork-Safety Fixes
fork_posix.cc:71happened:ReleaseExecutestorm (reattach.py): PySpark'sExecutePlanResponseReattachableIteratorsubmits a backgroundReleaseExecute(ReleaseUntil)RPC to a sharedThreadPoolExecutorfor every streamed Arrow batch plusReleaseAllonresult_complete, while returning to the caller immediately as soon asresult_completearrives. On a 500-batchdf.toPandas()call, 500+ backgroundReleaseExecuteRPCs continued firing into C-core gRPC for up to 13 seconds aftertoPandas()returned, colliding with any subsequent subprocess (!shell,gcloud,%pip,matplotlib) in the same or next notebook cell.connect_tcp_bridge()initialized credentials lazily on the proxy thread while the gRPC thread was already inside C-core waiting on the loopback socket, anygcloudor/opt/google/endpoint-verification/bin/apihelpersubprocess forked while a thread held an active gRPCExecCtx.ManagedSparkSessionProxy.stop()only closed the listener socket without shutting down active client/bridged sockets or joining forwarding threads.ReleaseUntilRPCs and quiesces on completion (client/reattach_patch.py): Allows at most 1 in-flightReleaseExecuteper iterator at a time, coalescing intermediateuntil_response_idupdates to the latest 1-batch-slack ID, aborting redundantReleaseUntils once_result_completeor_release_allis reached, and draining in-flight release futures (bounded byMANAGED_SPARK_CONNECT_RELEASE_DRAIN_TIMEOUT_S, default2.0s) before_execute_and_fetch/_execute_and_fetch_as_iterator/stop()returns. In live Dataproc benchmarks (1,000,000rows,514Arrow batches), this reducedReleaseExecuteRPCs from517to3–5(99%+ reduction) with0background gRPC threads remaining aftertoPandas()returns.SessionControllerClientbefore starting the proxy or gRPC channel (session.py,client/core.py): Runsget_refreshed_credentials()and_create_session_controller_client()on the calling thread before spinning upManagedSparkSessionProxyor the C-core gRPC channel.client/proxy.py,client/core.py,session.py): Tracks active client sockets, bridged WebSocket wrappers, and forwarding threads inManagedSparkSessionProxyand shuts them down (SHUT_RDWR) and joins threads onstop()/ProxiedChannel.close().GRPC_VERBOSITYtoERROR(__init__.py): Callsos.environ.setdefault("GRPC_VERBOSITY", "ERROR")before any transitivegrpcimport so benign C-coreI0000info logs fromfork()+exec()subprocesses are suppressed while preserving genuineERRORlogs and respecting user-setGRPC_VERBOSITY.2. Log, Warning, and Notebook Output Hygiene
logging.basicConfig(level=logging.INFO)fromsession.py(which previously attached a rootStreamHandler(sys.stderr)atINFOfor the entire Python process at import time) and attaches alogging.NullHandler()tologging.getLogger("google.cloud.managed_spark_connect")in__init__.py. SettingMANAGED_SPARK_CONNECT_LOG_LEVEL=<LEVEL>configures only thegoogle.cloud.managed_spark_connectlogger hierarchy.tqdmthreads or strayprint()s:tqdmprogress-bar thread (create_session_pbar) and its trailing newline on interactivity (environment.is_interactive()orMANAGED_SPARK_CONNECT_FORCE_PROGRESS_BAR=1).print(f"... Managed Spark Session deleted: {e}")calls inget_active_s8s_session_response()(which fired on routineNotFoundchecks when creating a customsessionId) and duplicatelogger.infocalls in_get_session_by_id()tologger.debug(...)._output_element_or_message()and keeps repeated in-processgetOrCreate()singleton returns silent.leavetoFalseso transientdf.columns/df.count()bars clean up after completion instead of stacking in notebook cells.Builder.showOperationLinks(bool)(MANAGED_SPARK_CONNECT_SHOW_OPERATION_LINKS),Builder.showProgressBar(bool)(MANAGED_SPARK_CONNECT_PROGRESS_BAR), andBuilder.progressBarLeave(bool)(MANAGED_SPARK_CONNECT_PROGRESS_BAR_LEAVE).runtime_versionstrings (e.g."2.3.10"->"2.3") before looking up inRUNTIME_PYTHON_MAPin_check_python_version_compatibility()so patch-level runtime versions do not emit false"Unrecognized Spark runtime version"warnings.SQLQueryContextLogger/DataFrameQueryContextLoggerraw JSONstderrdumps on query exceptions unlessSPARK_CONNECT_LOG_LEVELorMANAGED_SPARK_CONNECT_LOG_LEVELis set.Pandas4Warning: The copy keyword is deprecatedemitted insidetoPandas().pypi_artifacts.pyandProxiedChannel._check_active()inactive-session log lines (right before raisingRuntimeError) toDEBUG, addsstacklevel=2to the deprecated package warning in__init__.py, and replacesassert all(...)with a clean early return in_display_operation_link().Commits in this PR
fix(grpc): set default GRPC_VERBOSITY=ERROR, pre-warm credentials, and close proxy sockets cleanlyfix(reattach): coalesce concurrent ReleaseUntil RPCs and quiesce release pool on completionfix(logging): remove root basicConfig and gate session/query output on interactivity and builder knobsTesting
tests/unit/test_grpc_fork_noise.py,tests/unit/test_reattach_patch.py, andtests/unit/test_logging_hygiene.py.0fork_posix.cclines,0leakedtqdmbars,0stray stdout/stderr lines in non-interactive mode).