Skip to content

Commit b98e9da

Browse files
committed
fix: JobsProgress uses _MultiprocessingStorage with _ThreadSafeStorage as fallback
1 parent 6290b27 commit b98e9da

3 files changed

Lines changed: 157 additions & 79 deletions

File tree

runpod/serverless/modules/rp_scale.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ def start(self):
101101
signal.signal(signal.SIGTERM, self.handle_shutdown)
102102
signal.signal(signal.SIGINT, self.handle_shutdown)
103103
except ValueError:
104-
log.warning("Signal handling is only supported in the main thread.")
104+
log.warn("Signal handling is only supported in the main thread.")
105105

106106
# Start the main loop
107107
# Run forever until the worker is signalled to shut down.

runpod/serverless/modules/worker_state.py

Lines changed: 139 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@
55
import os
66
import time
77
import uuid
8+
import threading
89
from multiprocessing import Manager
910
from multiprocessing.managers import SyncManager
10-
from typing import Any, Dict, Optional
11+
from typing import Any, Dict, Optional, List
1112

1213
from .rp_logger import RunPodLogger
1314

@@ -63,45 +64,148 @@ def __str__(self) -> str:
6364
# ---------------------------------------------------------------------------- #
6465
# Tracker #
6566
# ---------------------------------------------------------------------------- #
67+
68+
class _JobStorage:
69+
"""Abstract storage backend for jobs."""
70+
71+
def add_job(self, job_dict: Dict[str, Any]) -> None:
72+
raise NotImplementedError
73+
74+
def remove_job(self, job_id: str) -> None:
75+
raise NotImplementedError
76+
77+
def get_job(self, job_id: str) -> Optional[Dict[str, Any]]:
78+
raise NotImplementedError
79+
80+
def get_all_jobs(self) -> List[Dict[str, Any]]:
81+
raise NotImplementedError
82+
83+
def clear_jobs(self) -> None:
84+
raise NotImplementedError
85+
86+
def job_exists(self, job_id: str) -> bool:
87+
raise NotImplementedError
88+
89+
90+
class _MultiprocessingStorage(_JobStorage):
91+
"""Multiprocessing-based storage for GIL-free operation."""
92+
93+
def __init__(self):
94+
self._manager = Manager()
95+
self._shared_data = self._manager.dict()
96+
self._shared_data['jobs'] = self._manager.list()
97+
self._lock = self._manager.Lock()
98+
99+
def add_job(self, job_dict: Dict[str, Any]) -> None:
100+
with self._lock:
101+
job_list = self._shared_data['jobs']
102+
if not any(job['id'] == job_dict['id'] for job in job_list):
103+
job_list.append(job_dict)
104+
105+
def remove_job(self, job_id: str) -> None:
106+
with self._lock:
107+
job_list = self._shared_data['jobs']
108+
for i, job in enumerate(job_list):
109+
if job['id'] == job_id:
110+
del job_list[i]
111+
break
112+
113+
def get_job(self, job_id: str) -> Optional[Dict[str, Any]]:
114+
with self._lock:
115+
for job in self._shared_data['jobs']:
116+
if job['id'] == job_id:
117+
return dict(job)
118+
return None
119+
120+
def get_all_jobs(self) -> List[Dict[str, Any]]:
121+
with self._lock:
122+
return [dict(job) for job in self._shared_data['jobs']]
123+
124+
def clear_jobs(self) -> None:
125+
with self._lock:
126+
self._shared_data['jobs'][:] = []
127+
128+
def job_exists(self, job_id: str) -> bool:
129+
with self._lock:
130+
return any(job['id'] == job_id for job in self._shared_data['jobs'])
131+
132+
133+
class _ThreadSafeStorage(_JobStorage):
134+
"""Thread-safe storage fallback when multiprocessing fails."""
135+
136+
def __init__(self):
137+
self._jobs: List[Dict[str, Any]] = []
138+
self._lock = threading.Lock()
139+
140+
def add_job(self, job_dict: Dict[str, Any]) -> None:
141+
with self._lock:
142+
if not any(job['id'] == job_dict['id'] for job in self._jobs):
143+
self._jobs.append(job_dict)
144+
145+
def remove_job(self, job_id: str) -> None:
146+
with self._lock:
147+
for i, job in enumerate(self._jobs):
148+
if job['id'] == job_id:
149+
del self._jobs[i]
150+
break
151+
152+
def get_job(self, job_id: str) -> Optional[Dict[str, Any]]:
153+
with self._lock:
154+
for job in self._jobs:
155+
if job['id'] == job_id:
156+
return job.copy()
157+
return None
158+
159+
def get_all_jobs(self) -> List[Dict[str, Any]]:
160+
with self._lock:
161+
return self._jobs.copy()
162+
163+
def clear_jobs(self) -> None:
164+
with self._lock:
165+
self._jobs.clear()
166+
167+
def job_exists(self, job_id: str) -> bool:
168+
with self._lock:
169+
return any(job['id'] == job_id for job in self._jobs)
170+
171+
66172
class JobsProgress:
67-
"""Track the state of current jobs in progress using shared memory."""
173+
"""Track the state of current jobs in progress using shared memory or thread-safe fallback."""
68174

69175
_instance: Optional['JobsProgress'] = None
70-
# Singleton
176+
_storage: Optional[_JobStorage] = None
177+
71178
def __new__(cls):
72179
if cls._instance is None:
73-
cls._instance = super().__new__(cls)
180+
cls._instance = object.__new__(cls)
181+
cls._instance._storage = None
74182
return cls._instance
75183

76184
def __init__(self):
77-
if not hasattr(self, '_initialized'):
78-
self._manager: Optional[SyncManager] = None
79-
self._shared_data: Optional[Any] = None
80-
self._lock: Optional[Any] = None
81-
self._initialized = True
82-
185+
pass
186+
83187
def _ensure_initialized(self):
84-
"""Initialize the multiprocessing manager and shared data structures only when needed."""
85-
if self._manager is None:
86-
self._manager = Manager()
87-
self._shared_data = self._manager.dict()
88-
self._shared_data['jobs'] = self._manager.list()
89-
self._lock = self._manager.Lock()
188+
"""Lazily initialize storage backend."""
189+
if self._storage is None:
190+
try:
191+
self._storage = _MultiprocessingStorage()
192+
log.debug("JobsProgress | Using multiprocessing for GIL-free operation")
193+
except Exception as e:
194+
log.warn(f"JobsProgress | Multiprocessing failed ({e}), falling back to thread-safe mode")
195+
self._storage = _ThreadSafeStorage()
90196

91197
def __repr__(self) -> str:
92198
return f"<{self.__class__.__name__}>: {self.get_job_list()}"
93199

94200
def clear(self) -> None:
95201
self._ensure_initialized()
96-
with self._lock:
97-
self._shared_data['jobs'][:] = []
202+
self._storage.clear_jobs()
98203

99204
def add(self, element: Any):
100205
"""
101206
Adds a Job object to the set.
102207
"""
103208
self._ensure_initialized()
104-
105209
if isinstance(element, str):
106210
job_dict = {'id': element}
107211
elif isinstance(element, dict):
@@ -111,16 +215,8 @@ def add(self, element: Any):
111215
else:
112216
raise TypeError("Only Job objects can be added to JobsProgress.")
113217

114-
with self._lock:
115-
# Check if job already exists
116-
job_list = self._shared_data['jobs']
117-
for existing_job in job_list:
118-
if existing_job['id'] == job_dict['id']:
119-
return # Job already exists
120-
121-
# Add new job
122-
job_list.append(job_dict)
123-
log.debug(f"JobsProgress | Added job: {job_dict['id']}")
218+
self._storage.add_job(job_dict)
219+
log.debug(f"JobsProgress | Added job: {job_dict['id']}")
124220

125221
def get(self, element: Any) -> Optional[Job]:
126222
"""
@@ -129,28 +225,24 @@ def get(self, element: Any) -> Optional[Job]:
129225
If the element is a string, searches for Job with that id.
130226
"""
131227
self._ensure_initialized()
132-
133228
if isinstance(element, str):
134229
search_id = element
135230
elif isinstance(element, Job):
136231
search_id = element.id
137232
else:
138233
raise TypeError("Only Job objects can be retrieved from JobsProgress.")
139234

140-
with self._lock:
141-
for job_dict in self._shared_data['jobs']:
142-
if job_dict['id'] == search_id:
143-
log.debug(f"JobsProgress | Retrieved job: {job_dict['id']}")
144-
return Job(**job_dict)
145-
235+
job_dict = self._storage.get_job(search_id)
236+
if job_dict:
237+
log.debug(f"JobsProgress | Retrieved job: {job_dict['id']}")
238+
return Job(**job_dict)
146239
return None
147240

148241
def remove(self, element: Any):
149242
"""
150243
Removes a Job object from the set.
151244
"""
152245
self._ensure_initialized()
153-
154246
if isinstance(element, str):
155247
job_id = element
156248
elif isinstance(element, dict):
@@ -160,23 +252,15 @@ def remove(self, element: Any):
160252
else:
161253
raise TypeError("Only Job objects can be removed from JobsProgress.")
162254

163-
with self._lock:
164-
job_list = self._shared_data['jobs']
165-
for i, job_dict in enumerate(job_list):
166-
if job_dict['id'] == job_id:
167-
del job_list[i]
168-
log.debug(f"JobsProgress | Removed job: {job_dict['id']}")
169-
break
255+
self._storage.remove_job(job_id)
256+
log.debug(f"JobsProgress | Removed job: {job_id}")
170257

171258
def get_job_list(self) -> Optional[str]:
172259
"""
173260
Returns the list of job IDs as comma-separated string.
174261
"""
175-
if self._manager is None:
176-
return None
177-
178-
with self._lock:
179-
job_list = list(self._shared_data['jobs'])
262+
self._ensure_initialized()
263+
job_list = self._storage.get_all_jobs()
180264

181265
if not job_list:
182266
return None
@@ -188,22 +272,13 @@ def get_job_count(self) -> int:
188272
"""
189273
Returns the number of jobs.
190274
"""
191-
if self._manager is None:
192-
return 0
193-
194-
with self._lock:
195-
return len(self._shared_data['jobs'])
275+
self._ensure_initialized()
276+
return len(self._storage.get_all_jobs())
196277

197278
def __iter__(self):
198279
"""Make the class iterable - returns Job objects"""
199-
if self._manager is None:
200-
return iter([])
201-
202-
with self._lock:
203-
# Create a snapshot of jobs to avoid holding lock during iteration
204-
job_dicts = list(self._shared_data['jobs'])
205-
206-
# Return an iterator of Job objects
280+
self._ensure_initialized()
281+
job_dicts = self._storage.get_all_jobs()
207282
return iter(Job(**job_dict) for job_dict in job_dicts)
208283

209284
def __len__(self):
@@ -212,9 +287,7 @@ def __len__(self):
212287

213288
def __contains__(self, element: Any) -> bool:
214289
"""Support 'in' operator"""
215-
if self._manager is None:
216-
return False
217-
290+
self._ensure_initialized()
218291
if isinstance(element, str):
219292
search_id = element
220293
elif isinstance(element, Job):
@@ -224,8 +297,4 @@ def __contains__(self, element: Any) -> bool:
224297
else:
225298
return False
226299

227-
with self._lock:
228-
for job_dict in self._shared_data['jobs']:
229-
if job_dict['id'] == search_id:
230-
return True
231-
return False
300+
return self._storage.job_exists(search_id)

tests/test_cli/test_cli_sanity.py

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -84,23 +84,32 @@ def test_import_safety(self):
8484
try:
8585
from runpod.serverless.modules.worker_state import JobsProgress
8686
jobs = JobsProgress()
87-
# Ensure lazy initialization is working
88-
self.assertIsNone(jobs._manager,
89-
"Manager should not be created until first use")
87+
# Ensure lazy initialization is working - storage should be None until first use
88+
self.assertIsNone(jobs._storage,
89+
"Storage backend should not be created until first use")
9090
self.assertTrue(True, "JobsProgress import and instantiation successful")
9191
except Exception as e:
9292
self.fail(f"Failed to import/instantiate JobsProgress: {e}")
9393

94-
# Test that read-only operations work efficiently
94+
# Test that operations trigger proper initialization
9595
try:
9696
from runpod.serverless.modules.worker_state import JobsProgress
9797
jobs = JobsProgress()
98-
count = jobs.get_job_count() # Should work without heavy initialization
98+
# Initially no storage backend
99+
self.assertIsNone(jobs._storage, "Storage should be None initially")
100+
101+
# First operation should initialize storage backend
102+
count = jobs.get_job_count()
99103
self.assertEqual(count, 0)
100-
self.assertIsNone(jobs._manager,
101-
"Manager should not be created for read-only operations")
104+
self.assertIsNotNone(jobs._storage,
105+
"Storage backend should be created after first operation")
106+
107+
# Verify storage backend is one of the expected types
108+
from runpod.serverless.modules.worker_state import _MultiprocessingStorage, _ThreadSafeStorage
109+
self.assertIsInstance(jobs._storage, (_MultiprocessingStorage, _ThreadSafeStorage),
110+
"Storage should be either multiprocessing or thread-safe backend")
102111
except Exception as e:
103-
self.fail(f"Read-only operations failed: {e}")
112+
self.fail(f"Storage initialization failed: {e}")
104113

105114
def test_cli_entry_point_import(self):
106115
"""

0 commit comments

Comments
 (0)