Skip to content

Commit 3102498

Browse files
committed
fix: JobsProgress was not sharing state properly between multiprocesses
1 parent b98e9da commit 3102498

3 files changed

Lines changed: 506 additions & 190 deletions

File tree

runpod/serverless/modules/worker_state.py

Lines changed: 87 additions & 139 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import threading
99
from multiprocessing import Manager
1010
from multiprocessing.managers import SyncManager
11-
from typing import Any, Dict, Optional, List
11+
from typing import Any, Dict, Optional
1212

1313
from .rp_logger import RunPodLogger
1414

@@ -64,148 +64,52 @@ def __str__(self) -> str:
6464
# ---------------------------------------------------------------------------- #
6565
# Tracker #
6666
# ---------------------------------------------------------------------------- #
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-
17267
class JobsProgress:
17368
"""Track the state of current jobs in progress using shared memory or thread-safe fallback."""
17469

17570
_instance: Optional['JobsProgress'] = None
176-
_storage: Optional[_JobStorage] = None
71+
_manager: Optional[SyncManager] = None
72+
_shared_data: Optional[Any] = None
73+
_lock: Optional[Any] = None
74+
_use_multiprocessing: bool = True
17775

17876
def __new__(cls):
17977
if cls._instance is None:
180-
cls._instance = object.__new__(cls)
181-
cls._instance._storage = None
182-
return cls._instance
183-
184-
def __init__(self):
185-
pass
186-
187-
def _ensure_initialized(self):
188-
"""Lazily initialize storage backend."""
189-
if self._storage is None:
78+
instance = object.__new__(cls)
79+
# Initialize multiprocessing objects directly like the original
19080
try:
191-
self._storage = _MultiprocessingStorage()
81+
instance._manager = Manager()
82+
instance._shared_data = instance._manager.dict()
83+
instance._shared_data['jobs'] = instance._manager.list()
84+
instance._lock = instance._manager.Lock()
85+
instance._use_multiprocessing = True
19286
log.debug("JobsProgress | Using multiprocessing for GIL-free operation")
19387
except Exception as e:
19488
log.warn(f"JobsProgress | Multiprocessing failed ({e}), falling back to thread-safe mode")
195-
self._storage = _ThreadSafeStorage()
89+
instance._fallback_jobs = []
90+
instance._fallback_lock = threading.Lock()
91+
instance._use_multiprocessing = False
92+
cls._instance = instance
93+
return cls._instance
94+
95+
def __init__(self):
96+
pass
19697

19798
def __repr__(self) -> str:
19899
return f"<{self.__class__.__name__}>: {self.get_job_list()}"
199100

200101
def clear(self) -> None:
201-
self._ensure_initialized()
202-
self._storage.clear_jobs()
102+
if self._use_multiprocessing:
103+
with self._lock:
104+
self._shared_data['jobs'][:] = []
105+
else:
106+
with self._fallback_lock:
107+
self._fallback_jobs.clear()
203108

204109
def add(self, element: Any):
205110
"""
206111
Adds a Job object to the set.
207112
"""
208-
self._ensure_initialized()
209113
if isinstance(element, str):
210114
job_dict = {'id': element}
211115
elif isinstance(element, dict):
@@ -215,7 +119,16 @@ def add(self, element: Any):
215119
else:
216120
raise TypeError("Only Job objects can be added to JobsProgress.")
217121

218-
self._storage.add_job(job_dict)
122+
if self._use_multiprocessing:
123+
with self._lock:
124+
job_list = self._shared_data['jobs']
125+
if not any(job['id'] == job_dict['id'] for job in job_list):
126+
job_list.append(job_dict)
127+
else:
128+
with self._fallback_lock:
129+
if not any(job['id'] == job_dict['id'] for job in self._fallback_jobs):
130+
self._fallback_jobs.append(job_dict)
131+
219132
log.debug(f"JobsProgress | Added job: {job_dict['id']}")
220133

221134
def get(self, element: Any) -> Optional[Job]:
@@ -224,25 +137,31 @@ def get(self, element: Any) -> Optional[Job]:
224137
225138
If the element is a string, searches for Job with that id.
226139
"""
227-
self._ensure_initialized()
228140
if isinstance(element, str):
229141
search_id = element
230142
elif isinstance(element, Job):
231143
search_id = element.id
232144
else:
233145
raise TypeError("Only Job objects can be retrieved from JobsProgress.")
234146

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)
147+
if self._use_multiprocessing:
148+
with self._lock:
149+
for job_dict in self._shared_data['jobs']:
150+
if job_dict['id'] == search_id:
151+
log.debug(f"JobsProgress | Retrieved job: {job_dict['id']}")
152+
return Job(**job_dict)
153+
else:
154+
with self._fallback_lock:
155+
for job_dict in self._fallback_jobs:
156+
if job_dict['id'] == search_id:
157+
log.debug(f"JobsProgress | Retrieved job: {job_dict['id']}")
158+
return Job(**job_dict)
239159
return None
240160

241161
def remove(self, element: Any):
242162
"""
243163
Removes a Job object from the set.
244164
"""
245-
self._ensure_initialized()
246165
if isinstance(element, str):
247166
job_id = element
248167
elif isinstance(element, dict):
@@ -252,15 +171,32 @@ def remove(self, element: Any):
252171
else:
253172
raise TypeError("Only Job objects can be removed from JobsProgress.")
254173

255-
self._storage.remove_job(job_id)
256-
log.debug(f"JobsProgress | Removed job: {job_id}")
174+
if self._use_multiprocessing:
175+
with self._lock:
176+
job_list = self._shared_data['jobs']
177+
for i, job_dict in enumerate(job_list):
178+
if job_dict['id'] == job_id:
179+
del job_list[i]
180+
log.debug(f"JobsProgress | Removed job: {job_dict['id']}")
181+
break
182+
else:
183+
with self._fallback_lock:
184+
for i, job_dict in enumerate(self._fallback_jobs):
185+
if job_dict['id'] == job_id:
186+
del self._fallback_jobs[i]
187+
log.debug(f"JobsProgress | Removed job: {job_dict['id']}")
188+
break
257189

258190
def get_job_list(self) -> Optional[str]:
259191
"""
260192
Returns the list of job IDs as comma-separated string.
261193
"""
262-
self._ensure_initialized()
263-
job_list = self._storage.get_all_jobs()
194+
if self._use_multiprocessing:
195+
with self._lock:
196+
job_list = list(self._shared_data['jobs'])
197+
else:
198+
with self._fallback_lock:
199+
job_list = list(self._fallback_jobs)
264200

265201
if not job_list:
266202
return None
@@ -272,13 +208,21 @@ def get_job_count(self) -> int:
272208
"""
273209
Returns the number of jobs.
274210
"""
275-
self._ensure_initialized()
276-
return len(self._storage.get_all_jobs())
211+
if self._use_multiprocessing:
212+
with self._lock:
213+
return len(self._shared_data['jobs'])
214+
else:
215+
with self._fallback_lock:
216+
return len(self._fallback_jobs)
277217

278218
def __iter__(self):
279219
"""Make the class iterable - returns Job objects"""
280-
self._ensure_initialized()
281-
job_dicts = self._storage.get_all_jobs()
220+
if self._use_multiprocessing:
221+
with self._lock:
222+
job_dicts = list(self._shared_data['jobs'])
223+
else:
224+
with self._fallback_lock:
225+
job_dicts = list(self._fallback_jobs)
282226
return iter(Job(**job_dict) for job_dict in job_dicts)
283227

284228
def __len__(self):
@@ -287,7 +231,6 @@ def __len__(self):
287231

288232
def __contains__(self, element: Any) -> bool:
289233
"""Support 'in' operator"""
290-
self._ensure_initialized()
291234
if isinstance(element, str):
292235
search_id = element
293236
elif isinstance(element, Job):
@@ -297,4 +240,9 @@ def __contains__(self, element: Any) -> bool:
297240
else:
298241
return False
299242

300-
return self._storage.job_exists(search_id)
243+
if self._use_multiprocessing:
244+
with self._lock:
245+
return any(job['id'] == search_id for job in self._shared_data['jobs'])
246+
else:
247+
with self._fallback_lock:
248+
return any(job['id'] == search_id for job in self._fallback_jobs)

0 commit comments

Comments
 (0)