Skip to content

fix(resilience): recover from mid-stream drops, blackholed connections, slow-consumer ping timeouts, and transient guard errors - #216

Open
dborowitz wants to merge 12 commits into
mainfrom
dborowitz-resilience
Open

dborowitz wants to merge 12 commits into
mainfrom
dborowitz-resilience

Conversation

@dborowitz

@dborowitz dborowitz commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Note

Followed by #217 (dborowitz-hygiene).

Summary

Hardens google-cloud-spark-connect against mid-stream transport drops, half-open WebSocket blackholes, slow-consumer backpressure, and Dataproc control-plane errors in the session liveness guard, while extending the opt-in instrumentation package (#210) with fine-grained proxy, auth, retry, guard, and progress-bar events.

Failure Modes Fixed

Failure Mode Root Cause Fix Live Verification Result
1. Mid-stream disconnect after ReleaseExecute (INVALID_CURSOR.POSITION_NOT_AVAILABLE) When a transport blip breaks an ExecutePlan stream and the server no longer buffers the response PySpark's ExecutePlanResponseReattachableIterator reattaches from (for example when extra frames queued in the proxy before the drop), ReattachExecute fails with INVALID_CURSOR.POSITION_NOT_AVAILABLE, which PySpark's DefaultPolicy does not retry. One-shot ExecutePlan replay (install_replay_on_position_lost in client/reattach_patch.py, configurable via MANAGED_SPARK_CONNECT_REPLAY_ON_POSITION_LOST, default 1): hooks client._execute_and_fetch to re-run read-only root plans once under a fresh operation_id (stripping prior reattach_options), closes the abandoned operation's progress bar (reason="replay"), logs a WARNING, and emits reattach.position_lost. command plans are never replayed. 100% recovery across ws_drop_mid_stream(early), ws_drop_mid_stream(after_release), and tcp_reset_mid_stream (2,000,000 rows, 0 unrecovered errors).
2. Half-open / blackholed WebSocket streams hanging forever In forward_bytes, server_to_client caught TimeoutError every 60s (WS_RECV_TIMEOUT_S) and continued indefinitely while client_to_server looped every 10s (SOCKET_TIMEOUT_S), leaving gRPC streams wedged forever when an intermediate hop blackholed traffic without a TCP FIN/RST. Track in-flight RPC count on ManagedSparkSessionProxy (inc_inflight / dec_inflight in _GuardedMultiCallable, _GuardedStream, and _GuardedFuture) and expose it to bridged_socket. When inflight_rpcs() > 0 and no server frame arrives for MANAGED_SPARK_CONNECT_WS_SILENCE_LIMIT_S (default 300s, 0 disables; pre-RPC idle time is excluded), log a WARNING, emit proxy.silence_cut, and shut down and close both ends so gRPC fails with UNAVAILABLE and triggers ReattachExecute. Half-open blackholes are cut once the silence limit expires and recover cleanly via ReattachExecute; long_query.silent_gap(75s) and sleep_udf(90s, 150s) completed with 0 false cuts.
3. Slow-consumer backpressure triggering WebSocket ping_timeout websockets.sync.client.connect() defaults to max_queue=16 and ping_timeout=20. When a client pauses reading for >20s (e.g. heavy Arrow/pandas conversion), the bounded recv_messages queue fills and blocks the WebSocket reader thread from processing incoming Pong frames, causing a self-inflicted keepalive disconnect. Pass max_queue=None (leaving backpressure bounded by HTTP/2 and TCP flow-control windows) and ping_timeout=60 in connect_tcp_bridge so a Pong queued behind in-flight server data frames does not time out prematurely. slow_consumer(30s) and slow_consumer(60s) completed 5,000,000 rows with 0 keepalive disconnects and 0 reattaches.
4. Liveness guard & session reuse failing closed on control-plane or auth errors get_active_s8s_session_response previously treated any error outside (503, 504, 429, 500) (such as an auth RefreshError or 403) as permanent session death (_DEAD_SESSIONS), and handled transient errors through three separate caches (_LIVENESS_CACHE, _LAST_KNOWN_ACTIVE with a 60s age limit, and _DEAD_SESSIONS) with inconsistent MANAGED_SPARK_CONNECT_LIVENESS_TTL_SECONDS semantics. Consolidate into a single _LIVENESS verdict cache and a single rule: a session is marked dead only when GetSession returns NotFound or a terminal state (not ACTIVE and not CREATING). All other errors propagate without touching the cache: ProxiedChannel._check_active fails open (proceeding with the RPC in pre_rpc mode or re-raising the original RPC error in on_error mode) and getOrCreate reuses an existing in-process session unchecked, logging once per outage at WARNING and subsequent failures at DEBUG (_OutageLog). Unifies and documents MANAGED_SPARK_CONNECT_LIVENESS_TTL_SECONDS. Transient control-plane 503s in guard_transient_503 and auth blips tolerated with 0 aborted queries, while genuinely terminated sessions still fast-fail in ~200 ms.
5. Peer socket thread hang & 10s WebSocket close stall on broken tunnels Closing a socket in one forward_bytes thread without shutdown(SHUT_RDWR) left the peer thread blocked in recv() until its timeout expired, and exiting websocketclient.connect waited 10s (close_timeout) for a Close handshake on dead connections. Call socket.shutdown(socket.SHUT_RDWR) before closing sockets in _shutdown_and_close (on EOF, silence cut, or forwarding error) and set close_timeout=3 on websocketclient.connect. Reconnect recovery time reduced by ~7–10s per dropped tunnel.

Commits in this PR

Instrumentation Additions (Commits 1–7)

  1. feat(instrumentation): proxy forwarding events — Emits proxy.eof (conn, direction, bytes_forwarded, age_ns, plus error_type and close_code on ConnectionClosed), proxy.socket_timeout (conn, direction, timeout_s), proxy.forward_error (conn, direction, error_type, message, including SendTimeout after 10 failed send retries), and proxy.ws_connect_failed (conn, error_type, http_status); closes both sockets on EOF.
  2. feat(instrumentation): auth.token_refreshed event — Emits auth.token_refreshed (expiry_wall_ns, had_token) whenever Application Default Credentials are refreshed in get_refreshed_credentials.
  3. feat(instrumentation): progress bar events, snapshot and headless test hook — Emits progress.bar_open, progress.bar_update, and progress.bar_close (reason=done|query_end|replay); skips creating a bar when the first update already has done=True; adds install_client_hooks / uninstall_client_hooks and wraps _execute_and_fetch on every session to close any leftover bar on query completion; adds ManagedSparkSession.progress_bars(), ManagedSparkSession._set_tqdm_factory(), and MANAGED_SPARK_CONNECT_FORCE_PROGRESS_BAR=1.
  4. feat(instrumentation): guard.result event for the liveness guard — Emits guard.result (mode=pre_rpc|on_error|direct, cached, active, error_type) when the liveness guard queries the control plane, finds the session inactive, or raises (cached active=True verdicts emit nothing).
  5. feat(instrumentation): grpc.retry event from pyspark's retry loop — Wraps client._retrying via install_client_hooks with InstrumentedRetrying to emit grpc.retry (attempt, reason=reattach|error, grpc_code, error_type) after each retry backoff.
  6. docs(instrumentation): document events and progress_bars() — Documents the Events catalogue and ManagedSparkSession.progress_bars() in docs/instrumentation.md and README.md.
  7. feat(instrumentation): plan_kind and command_kind on grpc.ExecutePlan spans — Tags grpc.ExecutePlan spans with plan_kind=root|command and, for commands, command_kind (e.g. sql_command, create_dataframe_view).

Resilience Fixes (Commits 8–12)

  1. fix(client): recover from INVALID_CURSOR.POSITION_NOT_AVAILABLE via one-shot replay — Adds client/reattach_patch.py (install_replay_on_position_lost, configurable via MANAGED_SPARK_CONNECT_REPLAY_ON_POSITION_LOST), emits reattach.position_lost, and wires it into ManagedSparkSession and README.md.
  2. fix(proxy): cut wedged streams after prolonged WebSocket silence — Tracks in-flight RPC count on ManagedSparkSessionProxy / bridged_socket and cuts connections silent for MANAGED_SPARK_CONNECT_WS_SILENCE_LIMIT_S (default 300s) while an RPC is in flight, emitting proxy.silence_cut.
  3. fix(proxy): prevent slow consumer from triggering keepalive ping timeout — Sets max_queue=None and ping_timeout=60 in connect_tcp_bridge.
  4. fix(session): fail open on control-plane errors in the liveness guard — Replaces _LIVENESS_CACHE, _LAST_KNOWN_ACTIVE, and _DEAD_SESSIONS with a single _LIVENESS verdict cache that only marks sessions dead on NotFound or terminal states, failing open with _OutageLog rate-limited logging in ProxiedChannel._check_active and in-process getOrCreate reuse, and documents MANAGED_SPARK_CONNECT_LIVENESS_TTL_SECONDS in README.md.
  5. fix(proxy): shutdown local socket before close and shorten ws close_timeout — Calls socket.shutdown(socket.SHUT_RDWR) before closing sockets in forward_bytes and passes close_timeout=3 to websocketclient.connect.

Testing

  • Added unit tests in tests/unit/test_instrumentation_events.py, tests/unit/test_reattach_patch.py, tests/unit/test_proxy.py, tests/unit/test_guard.py, tests/unit/test_core.py, and tests/unit/test_instrumentation.py.
  • Verified end-to-end against live Dataproc Serverless Spark Connect sessions across a 96-trial fault-injection, concurrency, boundary, progress-bar, cancellation, and long-query resilience suite (ws+guard, ws+noguard, and direct+noguard). Like the benchmark suite from perf: eliminate per-RPC control-plane polling, cache auth/clients, and optimize proxy framing #211, this is a custom AI-generated suite that I hope to open-source, but I'm getting the improvements in first.

@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 implements client-side recovery from position-lost errors via one-response release slack and bounded one-shot replays for root plans. It also enhances instrumentation with point-in-time events and tracks in-flight gRPC calls to cut wedged streams. The review feedback highlights critical synchronization issues where class-level shared dictionaries are protected by instance-level locks instead of class-level locks, as well as a type-hint mismatch in env_float where the default parameter should support None.

@dborowitz
dborowitz force-pushed the dborowitz-resilience branch 2 times, most recently from 13e0624 to 568cb6d Compare October 26, 2026 19:00
@dborowitz
dborowitz force-pushed the dborowitz-resilience branch 2 times, most recently from 97db870 to fb3ea49 Compare October 26, 2026 19:53
@dborowitz
dborowitz added this pull request to stack #220 October 26, 2026 19:54
@dborowitz
dborowitz force-pushed the dborowitz-resilience branch from fb3ea49 to 7d3c306 Compare October 27, 2026 19:33
@dborowitz
dborowitz force-pushed the dborowitz-resilience branch from 7d3c306 to b180595 Compare October 28, 2026 03:37
Base automatically changed from dborowitz-optimization to main October 28, 2026 21:33
@dborowitz
dborowitz force-pushed the dborowitz-resilience branch 2 times, most recently from 7839943 to a91334d Compare October 28, 2026 22:29
Emit point-in-time events from the local WebSocket proxy when
instrumentation is on (no-op recorder otherwise; the disabled path calls
connect_sockets exactly as before):

* proxy.eof           - a forwarding direction saw its source end close:
                        recv returned empty (gRPC side) or the websocket
                        raised ConnectionClosed (adds error_type,
                        close_code). attrs: conn, direction, bytes_forwarded,
                        age_ns since the connection was accepted.
* proxy.socket_timeout - a send timed out and is retried, with timeout_s
                        of the gRPC-side socket. Recv timeouts are routine
                        polls of an idle connection and emit nothing.
* proxy.forward_error - any other exception that ends a direction
                        (error_type, message truncated to 200 chars),
                        including SendTimeout after 10 failed send retries.
* proxy.ws_connect_failed - connect_tcp_bridge failed (auth or handshake),
                        with http_status when the error carries one.

Also names the 10 s socket / 60 s websocket timeouts as constants, and
closes both sockets when a direction sees EOF, as the error path already
did.
get_refreshed_credentials emits auth.token_refreshed (attrs expiry_wall_ns
of the new token, had_token = whether a token existed before) whenever it
actually refreshed the Application Default Credentials, so a long-lived
WebSocket's auth timeline can be reconstructed. No-op without
instrumentation.
…t hook

Make the notebook execution progress bar observable:

* Events (instrumented sessions only): progress.bar_open (operation_id,
  total_tasks) when the handler creates a tqdm bar; progress.bar_update
  (operation_id, completed_tasks, total_tasks) on every later handler call
  for that bar that does not finish it; progress.bar_close (operation_id,
  completed_tasks, total_tasks, reason) when it closes and unregisters it.
  A query whose first progress update already says done=True gets no bar.

* Every session (instrumented or not) wraps the client's _execute_and_fetch
  to close the query's bar in a finally, should the handler have left it
  open (reason=query_end); pyspark's Progress.__exit__ normally delivers the
  done=True that closes it (reason=done) before that.

* Per-instance client wrappers now go through one installer pair,
  install_client_hooks / uninstall_client_hooks: the first hook on a method
  records the original, and uninstalling restores every original at once.
  stop() removes them.

* ManagedSparkSession.progress_bars() returns dict[operation_id, bar]: a
  snapshot of the open bars, so tests and diagnostics no longer reach into
  the private class-level registry.

* Private test hook for running the handler outside a notebook:
  MANAGED_SPARK_CONNECT_FORCE_PROGRESS_BAR=1 makes the handler treat the
  environment as interactive, and ManagedSparkSession._set_tqdm_factory(cls)
  substitutes a tqdm-like class (None restores the default).

Events are inert for sessions without instrumentation; apart from closing a
leftover bar and skipping a bar for an already finished query, the handler's
behaviour in notebooks is unchanged.
The RPC liveness guard built by make_is_active_callback emits a
guard.result event when instrumentation is on: mode (pre_rpc | on_error,
as set by the guarded channel; direct outside it), cached (answered from
the liveness cache without a GetSession RPC), active (the verdict), and
error_type with active=None when the check itself raised. A cached
active verdict, which every healthy guarded RPC gets, emits nothing. The
existing dataproc.get_session span still covers the RPC. Without
instrumentation the callback runs exactly as before.
@dborowitz
dborowitz force-pushed the dborowitz-resilience branch from a91334d to 4743d18 Compare October 30, 2026 18:23
@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 client-side recovery from INVALID_CURSOR.POSITION_NOT_AVAILABLE errors by re-running root plans, adds environment variable parsing helpers, enhances instrumentation with several new events, and implements a silence cut mechanism in the proxy to prevent hanging streams. Key feedback includes addressing a potential memory leak/OOM risk from disabling WebSocket backpressure (max_queue=None), fixing a critical protobuf bug where clearing repeated composite fields invalidates references, wrapping a potentially throwing callable in a try-except block, replacing print statements with proper logging, and resolving encapsulation violations caused by importing private members across modules.

install_client_wrappers now also wraps the client's _retrying factory so
the Retrying it hands to pyspark's retry loops (including the reattachable
ExecutePlan iterator, which captures the factory when built) is a subclass
that emits grpc.retry after each wait that precedes a retry: attempt (1 =
first retry within that loop), reason (reattach for pyspark's
RetryException, error otherwise), and grpc_code and error_type of the
exception being retried. The failed RPC is the grpc.* span that ended just
before the event on the same thread. Per-instance, removed by
uninstall_client_wrappers; nothing is touched without instrumentation.
README: an Events table (proxy.*, auth.token_refreshed, progress.*,
guard.result, grpc.retry) next to the span catalogue, and a short section
on ManagedSparkSession.progress_bars().
… spans

The gRPC interceptor now tags ExecutePlan spans with plan_kind=root|command
(request.plan's op_type oneof) and, for commands, command_kind (the
command_type oneof name, e.g. sql_command, create_dataframe_view). Root
plans are the executions visible server-side as SQL queries; commands
(temp views, cache, conf, ...) never are, so consumers can restrict
server-side joins to root plans. Only non-empty string oneof names are
recorded; other request types are unaffected.
…ne-shot replay

When a transport blip breaks an ExecutePlan stream and the server no
longer buffers the response pyspark reattaches from, ReattachExecute
fails with INVALID_CURSOR.POSITION_NOT_AVAILABLE, which pyspark's
DefaultPolicy does not retry, so the whole query fails.

Add client/reattach_patch.py. install_replay_on_position_lost hooks the
client's _execute_and_fetch (every ManagedSparkSession installs it,
innermost, under the instrumentation and progress bar hooks). When a
root (read) plan fails with that error condition, it closes the
abandoned operation's progress bar (reason=replay), logs a WARNING and
re-runs a copy of the request once under a fresh operation_id. Command
plans are never replayed. Every such failure emits a
reattach.position_lost event. Opt out with
MANAGED_SPARK_CONNECT_REPLAY_ON_POSITION_LOST=0, documented in a new
README configuration section.
Pass max_queue=None and ping_timeout=60 to websocketclient.connect in
connect_tcp_bridge. In websockets.sync.client, a bounded recv_messages
queue (default 16) blocks the WebSocket reader thread when the consumer
is slow, preventing the reader thread from processing incoming Pong
frames within the default 20s ping_timeout and causing spurious
keepalive disconnects under backpressure.

ping_timeout goes from 20s to 60s because a Pong queues behind the data
frames the server is already sending, so it can arrive late on a
saturated link; a dead peer is still detected within ping_interval (20s)
plus ping_timeout.
Until now, a GetSession error other than 503/504/429/500 (an auth
RefreshError, a 403, anything unexpected) marked the session dead for
good, so one auth blip failed every later RPC. Transient errors fell
back to a last-known-active Session through several layers: a TTL
cache, _LAST_KNOWN_ACTIVE with a 60s age limit that pre_rpc ignored,
and separate meanings for unset, zero and negative TTLs.

Replace all of it with one rule: a session is dead only when GetSession
returns NotFound or a terminal state. get_active_s8s_session_response
lets every other error propagate and leaves the liveness cache alone:

- ProxiedChannel._check_active catches the error and lets the RPC
  proceed (after a failed RPC, it re-raises that RPC's own error). It
  logs a WARNING once per outage, then DEBUG until a check succeeds.
- getOrCreate reuses the session already in the process and logs the
  same way. With nothing to reuse (a first attach), it raises a
  ManagedSparkConnectException that names the session.

The liveness cache is now one dict of verdicts.
MANAGED_SPARK_CONNECT_LIVENESS_TTL_SECONDS still means seconds when
positive and no caching when 0. Unset and negative values now mean the
same thing: a cached ACTIVE verdict never expires, for the pre-RPC
guard and for direct checks alike. Before, unset cached for the guard
only. The variable is now documented in the README and read with
env_float. Tests patch an injectable clock (session._now) instead of
time.monotonic.
…imeout

1. Shut down both directions of sockets (socket.SHUT_RDWR) before
   closing in forward_bytes on EOF or forwarding error, so the peer
   thread blocked in recv() wakes immediately with EOF/reset instead of
   waiting for its socket timeout.
2. Pass close_timeout=3 to websocketclient.connect in connect_tcp_bridge
   so exiting the WebSocket context manager after a broken/reset backend
   does not wait 10s for a Close frame handshake that will never arrive.
@dborowitz
dborowitz force-pushed the dborowitz-resilience branch from 4743d18 to ea4a5dd Compare October 30, 2026 20:08
@dborowitz
dborowitz requested a review from tim-u October 30, 2026 20:22
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