Repository navigation
fix(spark): clean up session and port-forward on failure - #779
google-oss-prow[bot] merged 4 commits into
Conversation
|
🎉 Welcome to the Kubeflow SDK! 🎉 Thanks for opening your first PR! We're happy to have you as part of our community 🚀 Here's what happens next:
Join the community:
Feel free to ask questions in the comments if you need any help or clarification! |
There was a problem hiding this comment.
Hi @shreya-024! I have added a few comments.
Please also follow these instructions to sign your commits: https://github.com/kubeflow/sdk/pull/779/checks?check_run_id=100012874640.
| RUNNING = "Running" # Operator may set this when server is up; treated as ready | ||
| NOT_READY = "NotReady" | ||
| FAILED = "Failed" | ||
| UNKNOWN = "Unknown" |
There was a problem hiding this comment.
Please remove UNKNOWN = "Unknown".
| state = SparkConnectState(spark_connect_cr.status.state) | ||
| except ValueError: | ||
| state = SparkConnectState.PROVISIONING | ||
| state = SparkConnectState.UNKNOWN |
There was a problem hiding this comment.
Please use this instead.
sdk/kubeflow/common/constants.py
Lines 21 to 22 in 3febcd7
There was a problem hiding this comment.
The RUNNING state should be removed.
| if info.state == SparkConnectState.READY: | |
| logger.info( |
| @@ -32,6 +32,7 @@ class SparkConnectState(str, Enum): | |||
| RUNNING = "Running" # Operator may set this when server is up; treated as ready | |||
There was a problem hiding this comment.
The RUNNING state should be removed.
|
/ok-to-test |
|
/assign |
8d74bdc to
92e552c
Compare
vjkumar2756
left a comment
There was a problem hiding this comment.
LGTM
@shreya-024
I went through the changes, and the cleanup handling looks solid. Mapping unrecognized states to UNKNOWN, cleaning up the SparkConnect CR on failure, and terminating the port forward subprocess should prevent orphaned resources and state mismatches.
tariq-hasan
left a comment
There was a problem hiding this comment.
Hi @shreya-024! I have added a few comments.
| RUNNING = "Running" # Operator may set this when server is up; treated as ready | ||
| NOT_READY = "NotReady" | ||
| FAILED = "Failed" | ||
| UNKNOWN = "Unknown" |
There was a problem hiding this comment.
Please remove UNKNOWN = "Unknown".
There was a problem hiding this comment.
Let's rewrite as follows.
| state: str = common_constants.UNKNOWN |
There was a problem hiding this comment.
Same here
| status: str = common_constants.UNKNOWN |
| state = SparkConnectState(spark_connect_cr.status.state) | ||
| except ValueError: | ||
| state = SparkConnectState.PROVISIONING | ||
| state = SparkConnectState(common_constants.UNKNOWN) |
There was a problem hiding this comment.
| state = SparkConnectState(common_constants.UNKNOWN) | |
| state = common_constants.UNKNOWN |
There was a problem hiding this comment.
We need to be able to retry for transient errors.
There was a problem hiding this comment.
Please use the following implementation for _wait_for_session_ready. It handles transient errors with a retry mechanism.
def _wait_for_session_ready(
self,
name: str,
timeout: int = 300,
polling_interval: int = 2,
) -> SparkConnectInfo:
"""Wait for a SparkConnect session to become ready.
Args:
name:
Name of the SparkConnect session.
timeout:
Maximum time in seconds to wait.
polling_interval:
Time in seconds between status checks.
Returns:
SparkConnectInfo containing information about the ready session.
Raises:
RuntimeError:
If the SparkConnect session reaches the failed state.
TimeoutError:
If the session does not become ready within the timeout.
Exception:
If getting the session fails with a non-transient error.
"""
start_time = time.monotonic()
last_log_time = start_time
deadline = start_time + timeout
while True:
now = time.monotonic()
if now >= deadline:
raise TimeoutError(
f"Timeout waiting for {constants.SPARK_CONNECT_KIND} to be ready: "
f"{self.namespace}/{name} (timeout: {timeout}s)"
)
try:
info = self.get_session(name)
except TimeoutError as e:
# The request itself timed out. This is transient, so retry
# as long as the overall wait deadline has not expired.
logger.warning(
"Timeout getting session %s/%s, retrying: %s",
self.namespace,
name,
e,
)
except RuntimeError as e:
# get_session wraps ApiException in RuntimeError, so inspect the cause.
cause = e.__cause__
if not isinstance(cause, client.ApiException) or cause.status not in {
429,
500,
502,
503,
504,
}:
raise
logger.warning(
"Transient HTTP %s getting session %s/%s, retrying: %s",
cause.status,
self.namespace,
name,
e,
)
else:
if info.state == SparkConnectState.READY:
logger.info(
"Session ready: %s/%s state=%s serviceName=%s (%.0fs)",
self.namespace,
name,
info.state,
info.service_name,
time.monotonic() - start_time,
)
return info
if info.state == SparkConnectState.FAILED:
raise RuntimeError(
f"{constants.SPARK_CONNECT_KIND} failed: "
f"{self.namespace}/{name}"
)
now = time.monotonic()
if now - last_log_time >= 10.0:
logger.info(
"Waiting for session: %s/%s state=%s serviceName=%s elapsed=%.0fs",
self.namespace,
name,
info.state,
info.service_name,
now - start_time,
)
last_log_time = now
if now >= deadline:
raise TimeoutError(
f"Timeout waiting for {constants.SPARK_CONNECT_KIND} to be ready: "
f"{self.namespace}/{name} (timeout: {timeout}s)"
)
# Don't sleep beyond the overall deadline.
remaining = deadline - time.monotonic()
if remaining <= 0:
raise TimeoutError(
f"Timeout waiting for {constants.SPARK_CONNECT_KIND} to be ready: "
f"{self.namespace}/{name} (timeout: {timeout}s)"
)
time.sleep(min(polling_interval, remaining))
tariq-hasan
left a comment
There was a problem hiding this comment.
Hi @shreya-024! I have added a few more suggestions for changes.
| ) | ||
| with contextlib.suppress(Exception): | ||
| self.delete_session(info.name) | ||
| raise |
There was a problem hiding this comment.
We can rewrite this as follows. This is because Exception would not cover Ctrl+C keyboard interrupt for example. So to make the implementation more robust we use a finally block to always run session deletion if creation fails.
success = False
try:
info = self._wait_for_session_ready(info.name, timeout=timeout)
logger.info(
"Session ready, connecting (service_name=%s)",
info.service_name,
)
session = self.connect(
info,
connect_timeout=connect_timeout,
)
success = True
return session
finally:
if not success:
logger.warning(
"Failed to setup or connect to SparkConnect session %s/%s. "
"Cleaning up SparkConnect session.",
info.namespace,
info.name,
)
try:
self.delete_session(info.name)
except Exception:
logger.exception(
"Failed to clean up SparkConnect session %s/%s",
info.namespace,
info.name,
)
There was a problem hiding this comment.
Please use the following implementation for _wait_for_session_ready. It handles transient errors with a retry mechanism.
def _wait_for_session_ready(
self,
name: str,
timeout: int = 300,
polling_interval: int = 2,
) -> SparkConnectInfo:
"""Wait for a SparkConnect session to become ready.
Args:
name:
Name of the SparkConnect session.
timeout:
Maximum time in seconds to wait.
polling_interval:
Time in seconds between status checks.
Returns:
SparkConnectInfo containing information about the ready session.
Raises:
RuntimeError:
If the SparkConnect session reaches the failed state.
TimeoutError:
If the session does not become ready within the timeout.
Exception:
If getting the session fails with a non-transient error.
"""
start_time = time.monotonic()
last_log_time = start_time
deadline = start_time + timeout
while True:
now = time.monotonic()
if now >= deadline:
raise TimeoutError(
f"Timeout waiting for {constants.SPARK_CONNECT_KIND} to be ready: "
f"{self.namespace}/{name} (timeout: {timeout}s)"
)
try:
info = self.get_session(name)
except TimeoutError as e:
# The request itself timed out. This is transient, so retry
# as long as the overall wait deadline has not expired.
logger.warning(
"Timeout getting session %s/%s, retrying: %s",
self.namespace,
name,
e,
)
except RuntimeError as e:
# get_session wraps ApiException in RuntimeError, so inspect the cause.
cause = e.__cause__
if not isinstance(cause, client.ApiException) or cause.status not in {
429,
500,
502,
503,
504,
}:
raise
logger.warning(
"Transient HTTP %s getting session %s/%s, retrying: %s",
cause.status,
self.namespace,
name,
e,
)
else:
if info.state == SparkConnectState.READY:
logger.info(
"Session ready: %s/%s state=%s serviceName=%s (%.0fs)",
self.namespace,
name,
info.state,
info.service_name,
time.monotonic() - start_time,
)
return info
if info.state == SparkConnectState.FAILED:
raise RuntimeError(
f"{constants.SPARK_CONNECT_KIND} failed: "
f"{self.namespace}/{name}"
)
now = time.monotonic()
if now - last_log_time >= 10.0:
logger.info(
"Waiting for session: %s/%s state=%s serviceName=%s elapsed=%.0fs",
self.namespace,
name,
info.state,
info.service_name,
now - start_time,
)
last_log_time = now
if now >= deadline:
raise TimeoutError(
f"Timeout waiting for {constants.SPARK_CONNECT_KIND} to be ready: "
f"{self.namespace}/{name} (timeout: {timeout}s)"
)
# Don't sleep beyond the overall deadline.
remaining = deadline - time.monotonic()
if remaining <= 0:
raise TimeoutError(
f"Timeout waiting for {constants.SPARK_CONNECT_KIND} to be ready: "
f"{self.namespace}/{name} (timeout: {timeout}s)"
)
time.sleep(min(polling_interval, remaining))
| def test_create_and_connect_cleanup_on_failure(kubernetes_backend): | ||
| """Test that create_and_connect cleans up session when connect fails.""" | ||
| mock_info = MagicMock() | ||
| mock_info.name = "test-session" | ||
| mock_info.namespace = "default" | ||
|
|
||
| with ( | ||
| patch.object(kubernetes_backend, "_create_session", return_value=mock_info), | ||
| patch.object( | ||
| kubernetes_backend, | ||
| "_wait_for_session_ready", | ||
| side_effect=RuntimeError("Wait failed"), | ||
| ), | ||
| patch.object(kubernetes_backend, "delete_session") as mock_delete, | ||
| ): | ||
| with pytest.raises(RuntimeError, match="Wait failed"): | ||
| kubernetes_backend.create_and_connect() | ||
|
|
||
| mock_delete.assert_called_once_with("test-session") |
There was a problem hiding this comment.
Unit tests need to use the existing test_<method> function naming pattern with test cases using the TestCase helper.
Please also add test cases that cover
- create succeeds, connect succeeds
- create succeeds, wait fails
- create succeeds, connect fails
- create itself fails
There was a problem hiding this comment.
Please move all these test cases into test_create_and_connect.
The pattern we follow is to group test cases under test_<method_name> in each unit test module.
|
|
||
| # Parse state | ||
| state = SparkConnectState.PROVISIONING | ||
| state = common_constants.UNKNOWN |
There was a problem hiding this comment.
Actually I think it's better for this one to be state = SparkConnectState.PROVISIONING.
Granted the PROVISIONING state is not currently leveraged in the operator. At the same time it makes sense for the initial state on the client side to be a provisioning state and not an unknown state.
Please also remove the ready state from the following since only the provisioning state can be captured after CR creation before it is read back out from the control plane.
sdk/test/e2e/spark/test_spark_examples.py
Line 114 in 3febcd7
fdb5397 to
5fb36b3
Compare
|
Hi @tariq-hasan, I've addressed all the review comments. Could you please take a look when you get a chance? |
|
Thanks @shreya-024! Please sign the DCO commit as well: https://github.com/kubeflow/sdk/pull/779/checks?check_run_id=111507077564. |
Signed-off-by: shreya-024 <shreyak.2406@gmail.com>
Signed-off-by: shreya-024 <shreyak.2406@gmail.com>
Signed-off-by: shreya-024 <shreyak.2406@gmail.com>
Signed-off-by: shreya-024 <shreyak.2406@gmail.com>
8acdf84 to
b96c93d
Compare
|
@tariq-hasan I've signed the commit |
|
Thanks! |
|
[APPROVALNOTIFIER] This PR is APPROVED This pull-request has been approved by: andreyvelich The full list of commands accepted by this bot can be found here. The pull request process is described here DetailsNeeds approval from an approver in each of these files:
Approvers can indicate their approval by writing |
What this PR does / why we need it:
Fixes the SparkConnect resource handling gap identified in #476.
Which issue(s) this PR fixes: