1010from dagster_ray .configs import Lifecycle
1111from dagster_ray .kuberay .client import RayClusterClient
1212from dagster_ray .kuberay .configs import ClusterSharing , RayClusterConfig
13+ from dagster_ray .kuberay .leader_election import LeaderElection
1314from dagster_ray .kuberay .resources .base import BaseKubeRayResource
1415from dagster_ray .kuberay .utils import normalize_k8s_label_values
1516from 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