Skip to content

Commit 5a5719b

Browse files
authored
🐛 prevent creation of separate shared clusters via Kubernetes Lease API (#290)
1 parent ecef3ac commit 5a5719b

4 files changed

Lines changed: 279 additions & 34 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Added
1111

1212
- `RayCluster`'s head pod logs are now displayed on startup timeout or failure
13+
- Implemented Kubernetes Lease-based leader election for coordinating shared cluster creation. This replaces the previous flaky approach and guarantees that only one step creates the shared cluster when multiple parallel steps start simultaneously (within the same Dagster run).
1314

1415
### Fixes
1516

src/dagster_ray/kuberay/client/base.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,8 @@ def __init__(
3838
self.version = version
3939
self.kind = kind
4040
self.plural = plural
41-
self.api_client = api_client
42-
self._api = client.CustomObjectsApi(api_client=api_client)
41+
self.api_client = api_client or client.ApiClient()
42+
self._api = client.CustomObjectsApi(api_client=self.api_client)
4343
self._core_v1_api = client.CoreV1Api(api_client=api_client)
4444

4545
def wait_for_service_endpoints(
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
"""Leader election via Kubernetes Lease for coordinating shared cluster creation.
2+
3+
This module provides a Kubernetes Lease-based leader election mechanism to ensure
4+
only one step creates a shared RayCluster when multiple parallel steps start simultaneously.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import logging
10+
import time
11+
from collections.abc import Callable
12+
from dataclasses import dataclass
13+
from datetime import datetime, timezone
14+
from typing import TYPE_CHECKING
15+
16+
if TYPE_CHECKING:
17+
from kubernetes.client import ApiClient
18+
19+
logger = logging.getLogger(__name__)
20+
21+
22+
@dataclass
23+
class LeaderElectionResult:
24+
"""Result of a leader election attempt."""
25+
26+
is_leader: bool
27+
lease_name: str
28+
holder_identity: str
29+
30+
31+
class LeaderElection:
32+
"""Kubernetes Lease-based leader election for coordinating shared cluster creation.
33+
34+
Uses the `coordination.k8s.io/v1` `Lease` API to implement distributed leader election.
35+
"""
36+
37+
def __init__(
38+
self,
39+
holder_identity: str,
40+
api_client: ApiClient,
41+
lease_duration_seconds: int = 60,
42+
retry_period_seconds: float = 1.0,
43+
acquire_timeout_seconds: float = 60.0,
44+
):
45+
"""Initialize leader election.
46+
47+
Args:
48+
holder_identity: Unique identifier for this process
49+
api_client: Kubernetes API client
50+
lease_duration_seconds: How long the lease is held before expiring
51+
retry_period_seconds: How often to retry acquiring the lease
52+
acquire_timeout_seconds: Total time to wait for leader election
53+
"""
54+
from kubernetes import client
55+
56+
self.api_client = api_client
57+
self._coordination_api = client.CoordinationV1Api(api_client=api_client)
58+
self._holder_identity = holder_identity
59+
self.lease_duration_seconds = lease_duration_seconds
60+
self.retry_period_seconds = retry_period_seconds
61+
self.acquire_timeout_seconds = acquire_timeout_seconds
62+
63+
def acquire_or_wait(
64+
self,
65+
lease_name: str,
66+
namespace: str,
67+
resource_exists_check: Callable[[], bool],
68+
) -> LeaderElectionResult:
69+
"""Acquire leadership or wait for the resource to be created by the leader.
70+
71+
Args:
72+
lease_name: Name of the lease to use for coordination
73+
namespace: Kubernetes namespace
74+
resource_exists_check: Callable that returns True if the shared resource exists
75+
76+
Returns:
77+
LeaderElectionResult indicating whether this process should create the resource
78+
"""
79+
start_time = time.time()
80+
81+
while time.time() - start_time < self.acquire_timeout_seconds:
82+
# Check if resource already exists
83+
if resource_exists_check():
84+
logger.debug("Shared resource already exists, not acquiring leadership")
85+
# Try to get the current lease holder for logging purposes
86+
current_holder = self._get_lease_holder(lease_name, namespace)
87+
return LeaderElectionResult(
88+
is_leader=False,
89+
lease_name=lease_name,
90+
holder_identity=current_holder or "unknown",
91+
)
92+
93+
# Try to become the leader
94+
if self._try_create_lease(lease_name, namespace):
95+
logger.info(f"Acquired leadership by creating lease {namespace}/{lease_name}")
96+
return LeaderElectionResult(
97+
is_leader=True,
98+
lease_name=lease_name,
99+
holder_identity=self._holder_identity,
100+
)
101+
102+
# Someone else is the leader, wait for resource to appear
103+
current_holder = self._get_lease_holder(lease_name, namespace)
104+
logger.debug(f"Lease {namespace}/{lease_name} held by {current_holder}, waiting for resource")
105+
time.sleep(self.retry_period_seconds)
106+
107+
# Timeout - assume leadership to avoid deadlock
108+
logger.warning(f"Leader election timed out after {self.acquire_timeout_seconds}s, assuming leadership")
109+
return LeaderElectionResult(
110+
is_leader=True,
111+
lease_name=lease_name,
112+
holder_identity=self._holder_identity,
113+
)
114+
115+
def _try_create_lease(self, name: str, namespace: str) -> bool:
116+
"""Attempt to create a new lease. Returns True if successful (we're the leader)."""
117+
from kubernetes.client import ApiException
118+
from kubernetes.client.models import V1Lease, V1LeaseSpec, V1ObjectMeta
119+
120+
lease = V1Lease(
121+
metadata=V1ObjectMeta(name=name, namespace=namespace),
122+
spec=V1LeaseSpec(
123+
holder_identity=self._holder_identity,
124+
lease_duration_seconds=self.lease_duration_seconds,
125+
acquire_time=datetime.now(timezone.utc),
126+
),
127+
)
128+
129+
try:
130+
self._coordination_api.create_namespaced_lease(namespace, lease)
131+
return True
132+
except ApiException as e:
133+
if e.status == 409: # Already exists - someone else is the leader
134+
return False
135+
raise
136+
137+
def _get_lease_holder(self, name: str, namespace: str) -> str | None:
138+
"""Get the current holder identity from an existing lease."""
139+
from typing import cast
140+
141+
from kubernetes.client import ApiException
142+
from kubernetes.client.models import V1Lease
143+
144+
try:
145+
lease = cast(V1Lease, self._coordination_api.read_namespaced_lease(name=name, namespace=namespace))
146+
return lease.spec.holder_identity if lease.spec else None
147+
except ApiException as e:
148+
if e.status == 404:
149+
return None
150+
raise
151+
152+
def _delete_lease(self, name: str, namespace: str) -> None:
153+
"""Delete a lease."""
154+
from kubernetes.client import ApiException
155+
156+
try:
157+
self._coordination_api.delete_namespaced_lease(name=name, namespace=namespace)
158+
except ApiException as e:
159+
if e.status != 404:
160+
raise
161+
162+
def release(self, lease_name: str, namespace: str) -> None:
163+
"""Release leadership by deleting the lease."""
164+
self._delete_lease(lease_name, namespace)
165+
logger.debug(f"Released lease {namespace}/{lease_name}")

src/dagster_ray/kuberay/resources/raycluster.py

Lines changed: 111 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from dagster_ray.configs import Lifecycle
1111
from dagster_ray.kuberay.client import RayClusterClient
1212
from dagster_ray.kuberay.configs import ClusterSharing, RayClusterConfig
13+
from dagster_ray.kuberay.leader_election import LeaderElection
1314
from dagster_ray.kuberay.resources.base import BaseKubeRayResource
1415
from dagster_ray.kuberay.utils import normalize_k8s_label_values
1516
from dagster_ray.types import AnyDagsterContext
@@ -103,6 +104,52 @@ def get_image(self, context: AnyDagsterContext) -> str | None:
103104
assert context.dagster_run is not None
104105
return self.image or context.dagster_run.tags.get("dagster/image")
105106

107+
def _find_shared_cluster(self, label_selector: str) -> list[dict]:
108+
"""Find existing shared clusters matching the label selector."""
109+
return self.client.list(
110+
label_selector=label_selector,
111+
namespace=self.namespace,
112+
).get("items", [])
113+
114+
def _use_existing_cluster(self, context: AnyDagsterContext, cluster_name: str) -> None:
115+
"""Mark an existing cluster as being used by this step."""
116+
self._name = cluster_name
117+
self._creation_verb = "Using"
118+
119+
# Place a lock on the cluster using JSON patch to avoid overwriting existing annotations
120+
lock_annotations = self.get_sharing_lock_annotations(context)
121+
patch_operations = [
122+
{
123+
"op": "add",
124+
"path": f"/metadata/annotations/{key.replace('/', '~1')}",
125+
"value": value,
126+
}
127+
for key, value in lock_annotations.items()
128+
]
129+
130+
self.client.update_json_patch(
131+
name=cluster_name,
132+
namespace=self.namespace,
133+
body=patch_operations,
134+
)
135+
136+
def _get_lease_name(self, context: AnyDagsterContext) -> str:
137+
"""Generate a deterministic lease name for cluster sharing coordination.
138+
139+
The lease name must be the same for all steps that should share a cluster,
140+
so it's derived from the sharing label selector (which defines "same cluster" semantics).
141+
The label selector is sorted to ensure deterministic ordering.
142+
"""
143+
import hashlib
144+
145+
label_selector = self.get_sharing_label_selector(context)
146+
# Sort the label selector components for deterministic ordering
147+
# Label selector format is "key1=value1,key2=value2,..."
148+
sorted_selector = ",".join(sorted(label_selector.split(",")))
149+
# Create a short hash of the sorted label selector for the lease name
150+
selector_hash = hashlib.sha256(sorted_selector.encode()).hexdigest()[:12]
151+
return f"dagster-ray-{selector_hash}"
152+
106153
@override
107154
def create(self, context: AnyDagsterContext):
108155
assert context.log is not None
@@ -116,47 +163,65 @@ def create(self, context: AnyDagsterContext):
116163
context.log.info(
117164
f"RayCluster sharing is enabled. Looking for clusters matching label selector: {label_selector}"
118165
)
119-
# check whether a cluster matching the sharing config already exists
120-
matching_clusters = self.client.list(
121-
label_selector=label_selector,
122-
namespace=self.namespace,
123-
).get("items", [])
166+
167+
# Check if a shared cluster already exists
168+
matching_clusters = self._find_shared_cluster(label_selector)
124169
if matching_clusters:
125170
cluster_name = matching_clusters[0]["metadata"]["name"]
126171
context.log.info(
127-
f"Found {len(matching_clusters)} clusters matching the label selector. Using the first one: {cluster_name}"
128-
)
129-
self._name = cluster_name
130-
self._creation_verb = "Using"
131-
132-
# place a lock on the cluster
133-
# Create individual patch operations for each annotation to avoid replacing existing ones
134-
lock_annotations = self.get_sharing_lock_annotations(context)
135-
patch_operations = [
136-
{
137-
"op": "add",
138-
"path": f"/metadata/annotations/{key.replace('/', '~1')}",
139-
"value": value,
140-
}
141-
for key, value in lock_annotations.items()
142-
]
143-
144-
self.client.update_json_patch(
145-
name=cluster_name,
146-
namespace=self.namespace,
147-
body=patch_operations,
172+
f"Found {len(matching_clusters)} clusters matching the label selector. "
173+
f"Using the first one: {cluster_name}"
148174
)
149-
175+
self._use_existing_cluster(context, cluster_name)
150176
return
151-
else:
152-
context.log.info("No matching clusters found. Creating a new one.")
153177

154-
# mark the cluster as being used by this step
155-
annotations.update(self.get_sharing_lock_annotations(context))
178+
# No cluster exists - use leader election to coordinate creation
179+
context.log.info("No matching shared clusters found. Using leader election to coordinate cluster creation.")
180+
181+
lease_name = self._get_lease_name(context)
182+
holder_identity = f"{context.run_id}-{self.resource_uid}"
183+
leader_election = LeaderElection(
184+
holder_identity=holder_identity,
185+
api_client=self.client.api_client,
186+
lease_duration_seconds=10,
187+
retry_period_seconds=2.0,
188+
acquire_timeout_seconds=60.0,
189+
)
190+
191+
def cluster_exists() -> bool:
192+
return bool(self._find_shared_cluster(label_selector))
193+
194+
result = leader_election.acquire_or_wait(
195+
lease_name=lease_name,
196+
namespace=self.namespace,
197+
resource_exists_check=cluster_exists,
198+
)
199+
200+
if not result.is_leader:
201+
# Another step created the cluster while we were waiting
202+
matching_clusters = self._find_shared_cluster(label_selector)
203+
if matching_clusters:
204+
cluster_name = matching_clusters[0]["metadata"]["name"]
205+
context.log.info(f"Using cluster created by leader: {cluster_name}")
206+
self._use_existing_cluster(context, cluster_name)
207+
return
208+
else:
209+
# Edge case: leader created then deleted? Fall through to create
210+
context.log.warning(
211+
"Leader election indicated not leader, but no cluster found. Proceeding to create cluster."
212+
)
213+
214+
context.log.info("Won leader election - this step will create the shared cluster.")
215+
216+
# Mark the cluster as being used by this step
217+
annotations.update(self.get_sharing_lock_annotations(context))
218+
219+
# Release the lease after we're done creating (cluster existence is the coordination point now)
220+
# We'll release after successful creation below
156221

157222
self._name = self.ray_cluster.metadata.get("name") or self._get_step_name(context)
158223

159-
# just a safety measure, no need to recreate the cluster for step retries or smth
224+
# Safety measure: don't recreate the cluster for step retries
160225
if not self.client.get(
161226
name=self.name,
162227
namespace=self.namespace,
@@ -175,6 +240,20 @@ def create(self, context: AnyDagsterContext):
175240
if not resource:
176241
raise RuntimeError(f"Couldn't create {self.display_name}")
177242

243+
# Release the lease now that the cluster is created
244+
if self.cluster_sharing.enabled:
245+
try:
246+
lease_name = self._get_lease_name(context)
247+
holder_identity = f"{context.run_id}-{self.resource_uid}"
248+
leader_election = LeaderElection(
249+
holder_identity=holder_identity,
250+
api_client=self.client.api_client,
251+
)
252+
leader_election.release(lease_name, self.namespace)
253+
except Exception as e:
254+
# Non-fatal: lease will expire on its own
255+
context.log.debug(f"Failed to release leader election lease: {e}")
256+
178257
@override
179258
def wait(self, context: AnyDagsterContext):
180259
assert context.log is not None

0 commit comments

Comments
 (0)