2
2
3
3
import json
4
4
import logging
5
- from datetime import datetime
6
- from typing import Any , Dict , List , Optional
5
+ from typing import Any , Dict
7
6
from kubernetes import client , config
8
7
from kubernetes .client .rest import ApiException
9
8
10
- from jupyter_scheduler .models import Status
11
9
from jupyter_scheduler .utils import get_utc_timestamp
12
10
13
11
logger = logging .getLogger (__name__ )
@@ -42,7 +40,7 @@ def _init_k8s_client(self):
42
40
self .k8s_batch = client .BatchV1Api ()
43
41
self .k8s_core = client .CoreV1Api ()
44
42
45
- # Test connection
43
+ # Validate connectivity before proceeding
46
44
self .k8s_core .get_api_versions ()
47
45
except Exception as e :
48
46
logger .error (f"Failed to initialize K8s clients: { e } " )
@@ -58,15 +56,15 @@ def __exit__(self, exc_type, exc_val, exc_tb):
58
56
self .rollback ()
59
57
60
58
def query (self , model_class ):
61
- """Return K8s query object ."""
59
+ """Create query for model class ."""
62
60
return K8sQuery (self , model_class )
63
61
64
62
def add (self , job ):
65
- """Buffer job creation for batch commit."""
63
+ """Buffer job for batch commit."""
66
64
self ._pending_operations .append (('create' , job ))
67
65
68
66
def commit (self ):
69
- """Execute all buffered operations."""
67
+ """Execute buffered operations."""
70
68
if not self ._pending_operations :
71
69
return
72
70
@@ -86,11 +84,12 @@ def commit(self):
86
84
raise
87
85
88
86
def rollback (self ):
89
- """Clear pending operations (K8s doesn't support true rollback)."""
87
+ """Clear pending operations."""
88
+ # K8s doesn't support transactions, only clear pending operations
90
89
self ._pending_operations .clear ()
91
90
92
91
def _job_to_dict (self , job ) -> Dict [str , Any ]:
93
- """Convert SQLAlchemy Job model to dict."""
92
+ """Convert Job model to dict."""
94
93
return {
95
94
"job_id" : job .job_id ,
96
95
"name" : job .name ,
@@ -104,11 +103,12 @@ def _job_to_dict(self, job) -> Dict[str, Any]:
104
103
}
105
104
106
105
def _create_k8s_job (self , job_data : Dict ):
107
- """Create placeholder K8s Job for database storage."""
106
+ """Create K8s Job for metadata storage."""
107
+ # Creates minimal busybox job that stores metadata in labels/annotations
108
108
job_id = job_data ['job_id' ]
109
109
job_name = f"js-{ job_id [:8 ]} -{ job_id [- 4 :]} "
110
110
111
- # Create minimal job for metadata storage
111
+ # Busybox container runs once then exits, leaving metadata intact
112
112
job_spec = client .V1JobSpec (
113
113
template = client .V1PodTemplateSpec (
114
114
spec = client .V1PodSpec (
@@ -126,14 +126,14 @@ def _create_k8s_job(self, job_data: Dict):
126
126
backoff_limit = 0
127
127
)
128
128
129
- # Database labels for querying
129
+ # Labels enable fast K8s label selector queries
130
130
labels = {
131
131
"app.kubernetes.io/managed-by" : "jupyter-scheduler-k8s" ,
132
132
"jupyter-scheduler.io/job-id" : self ._sanitize (job_data ["job_id" ]),
133
133
"jupyter-scheduler.io/status" : self ._sanitize (job_data ["status" ]),
134
134
}
135
135
136
- # Add schedule indicator for Job vs JobDefinition differentiation
136
+ # Differentiate Job from JobDefinition using schedule presence
137
137
if job_data .get ("schedule" ):
138
138
labels ["jupyter-scheduler.io/has-schedule" ] = "true"
139
139
else :
@@ -142,7 +142,7 @@ def _create_k8s_job(self, job_data: Dict):
142
142
if job_data .get ("name" ):
143
143
labels ["jupyter-scheduler.io/name" ] = self ._sanitize (job_data ["name" ])
144
144
145
- # Full metadata in annotation
145
+ # Store complete job data in annotation for retrieval
146
146
annotations = {
147
147
"jupyter-scheduler.io/job-data" : json .dumps (job_data )
148
148
}
@@ -169,7 +169,8 @@ def _create_k8s_job(self, job_data: Dict):
169
169
raise
170
170
171
171
def _sanitize (self , value : str ) -> str :
172
- """Sanitize for K8s labels."""
172
+ """Sanitize value for K8s labels."""
173
+ # K8s labels must be alphanumeric, max 63 chars
173
174
value = str (value ).lower ()
174
175
value = '' .join (c if c .isalnum () or c in '-_.' else '-' for c in value )
175
176
return value .strip ('-_.' )[:63 ] or "none"
@@ -194,63 +195,63 @@ def __init__(self, session: K8sSession, model_class):
194
195
195
196
def filter (self , condition ):
196
197
"""Add filter condition."""
197
- # Parse SQLAlchemy-style condition
198
+ # Convert SQLAlchemy conditions to K8s label selectors or annotation filters
198
199
if hasattr (condition , 'left' ) and hasattr (condition .left , 'name' ):
199
200
field_name = condition .left .name
200
201
value = getattr (condition .right , 'value' , condition .right )
201
202
202
203
if field_name in ['job_id' , 'status' , 'name' ]:
203
204
self ._label_filters [f'jupyter-scheduler.io/{ field_name .replace ("_" , "-" )} ' ] = self .session ._sanitize (str (value ))
204
205
else :
205
- # Store for annotation-based filtering
206
+ # Complex fields stored in annotations, filtered post-query
206
207
self ._filters [field_name ] = value
207
208
elif hasattr (condition , 'type' ) and condition .type .name == 'in_' :
208
- # Handle IN clauses like Job.status.in_(['COMPLETED', 'FAILED'])
209
+ # IN clauses require annotation filtering since K8s labels don't support OR
209
210
field_name = condition .left .name
210
211
if field_name == 'status' :
211
- # For IN clauses, we'll need to handle multiple label selectors
212
+ # Multiple values require post-query filtering
212
213
self ._filters ['status_in' ] = [self .session ._sanitize (str (v )) for v in condition .right .value ]
213
214
214
215
return self
215
216
216
217
def update (self , values : Dict ):
217
- """Update job in K8s ."""
218
- # Build label selector from all label filters
218
+ """Update matching jobs ."""
219
+ # Use labels for efficient K8s filtering
219
220
label_selector = "," .join ([f"{ k } ={ v } " for k , v in self ._label_filters .items ()])
220
221
if not label_selector :
221
222
raise ValueError ("Update requires filterable conditions" )
222
223
223
- # Find and update K8s Jobs
224
+ # Query matching jobs using label selector
224
225
jobs = self .session .k8s_batch .list_namespaced_job (
225
226
namespace = self .session .namespace ,
226
227
label_selector = label_selector
227
228
)
228
229
229
230
for job in jobs .items :
230
- # Update annotation with new data
231
+ # Merge new values into existing job data
231
232
if job .metadata .annotations and "jupyter-scheduler.io/job-data" in job .metadata .annotations :
232
233
job_data = json .loads (job .metadata .annotations ["jupyter-scheduler.io/job-data" ])
233
234
job_data .update (values )
234
235
job_data ["update_time" ] = get_utc_timestamp ()
235
236
236
- # Update annotation
237
+ # Store updated data back to annotation
237
238
job .metadata .annotations ["jupyter-scheduler.io/job-data" ] = json .dumps (job_data )
238
239
239
- # Update corresponding labels if changed
240
+ # Sync searchable fields to labels for query performance
240
241
for field , value in values .items ():
241
242
if field in ['status' , 'name' ]:
242
243
label_key = f"jupyter-scheduler.io/{ field .replace ('_' , '-' )} "
243
244
job .metadata .labels [label_key ] = self .session ._sanitize (str (value ))
244
245
245
- # Patch the job
246
+ # Apply changes to K8s resource
246
247
self .session .k8s_batch .patch_namespaced_job (
247
248
name = job .metadata .name ,
248
249
namespace = self .session .namespace ,
249
250
body = job
250
251
)
251
252
252
253
def one (self ):
253
- """Get single job (throw if not found) ."""
254
+ """Get single job or raise ."""
254
255
result = self .first ()
255
256
if result is None :
256
257
raise ValueError ("Job not found" )
@@ -283,8 +284,8 @@ def delete(self):
283
284
)
284
285
285
286
def _get_matching_jobs (self ):
286
- """Get K8s jobs matching current filters."""
287
- # Build label selector
287
+ """Query jobs matching filters."""
288
+ # Use labels for efficient server-side filtering
288
289
label_selector = "," .join ([f"{ k } ={ v } " for k , v in self ._label_filters .items ()])
289
290
290
291
jobs = self .session .k8s_batch .list_namespaced_job (
@@ -297,14 +298,14 @@ def _get_matching_jobs(self):
297
298
if job .metadata .annotations and "jupyter-scheduler.io/job-data" in job .metadata .annotations :
298
299
job_data = json .loads (job .metadata .annotations ["jupyter-scheduler.io/job-data" ])
299
300
300
- # Apply annotation-based filters
301
+ # Post-filter using annotation data for complex conditions
301
302
if self ._matches_annotation_filters (job_data ):
302
303
results .append (self ._dict_to_job (job_data ))
303
304
304
305
return results
305
306
306
307
def _matches_annotation_filters (self , job_data : Dict ) -> bool :
307
- """Check if job data matches annotation-based filters ."""
308
+ """Check annotation-based filter matches ."""
308
309
for field , value in self ._filters .items ():
309
310
if field == 'status_in' :
310
311
if job_data .get ('status' ) not in value :
@@ -316,7 +317,7 @@ def _matches_annotation_filters(self, job_data: Dict) -> bool:
316
317
if not job_data .get ('start_time' ) or job_data ['start_time' ] < value :
317
318
return False
318
319
elif field .endswith ('_like' ):
319
- # Handle LIKE queries (e.g., name LIKE ' prefix%')
320
+ # SQL LIKE converted to string prefix matching
320
321
actual_field = field [:- 5 ]
321
322
actual_value = job_data .get (actual_field , "" )
322
323
if not actual_value .startswith (str (value ).rstrip ('%' )):
@@ -327,101 +328,10 @@ def _matches_annotation_filters(self, job_data: Dict) -> bool:
327
328
return True
328
329
329
330
def _dict_to_job (self , job_data : Dict ):
330
- """Convert dict back to Job-like object."""
331
+ """Convert dict to Job-like object."""
331
332
class JobRecord :
332
333
def __init__ (self , data ):
333
334
for k , v in data .items ():
334
335
setattr (self , k , v )
335
336
336
- return JobRecord (job_data )
337
-
338
-
339
- # Store original functions for fallback
340
- _original_create_session = None
341
- _original_create_tables = None
342
-
343
-
344
- def k8s_create_session (db_url ):
345
- """K8s session factory that replaces SQLAlchemy."""
346
- if db_url .startswith ("k8s://" ):
347
- namespace = db_url [6 :] or "default"
348
- def session_factory ():
349
- return K8sSession (namespace = namespace )
350
- return session_factory
351
- else :
352
- # Fallback to original SQLAlchemy implementation
353
- if _original_create_session :
354
- return _original_create_session (db_url )
355
- else :
356
- # Import here to avoid circular imports
357
- from jupyter_scheduler .orm import create_session as original_create_session
358
- return original_create_session (db_url )
359
-
360
-
361
- def k8s_create_tables (db_url , drop_tables = False , Base = None ):
362
- """K8s equivalent of create_tables - ensure namespace exists."""
363
- if db_url .startswith ("k8s://" ):
364
- namespace = db_url [6 :] or "default"
365
-
366
- try :
367
- config .load_incluster_config ()
368
- except config .ConfigException :
369
- config .load_kube_config ()
370
-
371
- k8s_core = client .CoreV1Api ()
372
-
373
- # Ensure namespace exists
374
- try :
375
- k8s_core .read_namespace (name = namespace )
376
- except ApiException as e :
377
- if e .status == 404 :
378
- ns = client .V1Namespace (metadata = client .V1ObjectMeta (name = namespace ))
379
- k8s_core .create_namespace (body = ns )
380
- logger .info (f"Created K8s namespace: { namespace } " )
381
-
382
- logger .info (f"K8s database initialized in namespace: { namespace } " )
383
- else :
384
- # Fallback to original SQLAlchemy implementation
385
- if _original_create_tables :
386
- return _original_create_tables (db_url , drop_tables , Base )
387
- else :
388
- from jupyter_scheduler .orm import create_tables as original_create_tables
389
- return original_create_tables (db_url , drop_tables , Base )
390
-
391
-
392
- def install_k8s_backend ():
393
- """Install K8s backend by monkey patching jupyter_scheduler.orm functions."""
394
- try :
395
- import jupyter_scheduler .orm as orm
396
-
397
- # Store originals for fallback
398
- global _original_create_session , _original_create_tables
399
- _original_create_session = orm .create_session
400
- _original_create_tables = orm .create_tables
401
-
402
- # Replace with K8s-aware functions
403
- orm .create_session = k8s_create_session
404
- orm .create_tables = k8s_create_tables
405
-
406
- # Also monkey patch SQLAlchemy's create_engine to handle k8s:// URLs
407
- import sqlalchemy
408
- original_create_engine = sqlalchemy .create_engine
409
-
410
- def k8s_aware_create_engine (url , * args , ** kwargs ):
411
- if str (url ).startswith ("k8s://" ):
412
- # Return a dummy engine object that won't be used
413
- # since our k8s_create_tables handles k8s:// URLs
414
- class DummyEngine :
415
- dialect = type ('dialect' , (), {'name' : 'k8s' })()
416
- return DummyEngine ()
417
- return original_create_engine (url , * args , ** kwargs )
418
-
419
- sqlalchemy .create_engine = k8s_aware_create_engine
420
-
421
- logger .info ("K8s database backend installed successfully" )
422
- except ImportError :
423
- logger .warning ("jupyter_scheduler not found, K8s backend not installed" )
424
-
425
-
426
- # Auto-install K8s backend when this module is imported
427
- install_k8s_backend ()
337
+ return JobRecord (job_data )
0 commit comments