Skip to content

Commit 555ee85

Browse files
committed
fix: lazy-loaded get_heartbeat and get_jobs_progress
1 parent 3102498 commit 555ee85

10 files changed

Lines changed: 119 additions & 71 deletions

File tree

runpod/serverless/modules/rp_fastapi.py

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@
1616
from ...version import __version__ as runpod_version
1717
from .rp_handler import is_generator
1818
from .rp_job import run_job, run_job_generator
19-
from .rp_ping import Heartbeat
20-
from .worker_state import Job, JobsProgress
19+
from .worker_state import Job, get_jobs_progress
20+
from .rp_ping import get_heartbeat
2121

2222
RUNPOD_ENDPOINT_ID = os.environ.get("RUNPOD_ENDPOINT_ID", None)
2323

@@ -96,8 +96,6 @@
9696

9797

9898
# ------------------------------ Initializations ----------------------------- #
99-
job_list = JobsProgress()
100-
heartbeat = Heartbeat()
10199

102100

103101
# ------------------------------- Input Objects ------------------------------ #
@@ -185,7 +183,7 @@ def __init__(self, config: Dict[str, Any]):
185183
3. Sets the handler for processing jobs.
186184
"""
187185
# Start the heartbeat thread.
188-
heartbeat.start_ping()
186+
get_heartbeat().start_ping()
189187

190188
self.config = config
191189

@@ -286,12 +284,12 @@ async def _realtime(self, job: Job):
286284
Performs model inference on the input data using the provided handler.
287285
If handler is not provided, returns an error message.
288286
"""
289-
job_list.add(job.id)
287+
get_jobs_progress().add(job.id)
290288

291289
# Process the job using the provided handler, passing in the job input.
292290
job_results = await run_job(self.config["handler"], job.__dict__)
293291

294-
job_list.remove(job.id)
292+
get_jobs_progress().remove(job.id)
295293

296294
# Return the results of the job processing.
297295
return jsonable_encoder(job_results)
@@ -304,7 +302,7 @@ async def _realtime(self, job: Job):
304302
async def _sim_run(self, job_request: DefaultRequest) -> JobOutput:
305303
"""Development endpoint to simulate run behavior."""
306304
assigned_job_id = f"test-{uuid.uuid4()}"
307-
job_list.add({
305+
get_jobs_progress().add({
308306
"id": assigned_job_id,
309307
"input": job_request.input,
310308
"webhook": job_request.webhook
@@ -345,7 +343,7 @@ async def _sim_runsync(self, job_request: DefaultRequest) -> JobOutput:
345343
# ---------------------------------- stream ---------------------------------- #
346344
async def _sim_stream(self, job_id: str) -> StreamOutput:
347345
"""Development endpoint to simulate stream behavior."""
348-
stashed_job = job_list.get(job_id)
346+
stashed_job = get_jobs_progress().get(job_id)
349347
if stashed_job is None:
350348
return jsonable_encoder(
351349
{"id": job_id, "status": "FAILED", "error": "Job ID not found"}
@@ -367,7 +365,7 @@ async def _sim_stream(self, job_id: str) -> StreamOutput:
367365
}
368366
)
369367

370-
job_list.remove(job.id)
368+
get_jobs_progress().remove(job.id)
371369

372370
if stashed_job.webhook:
373371
thread = threading.Thread(
@@ -384,7 +382,7 @@ async def _sim_stream(self, job_id: str) -> StreamOutput:
384382
# ---------------------------------- status ---------------------------------- #
385383
async def _sim_status(self, job_id: str) -> JobOutput:
386384
"""Development endpoint to simulate status behavior."""
387-
stashed_job = job_list.get(job_id)
385+
stashed_job = get_jobs_progress().get(job_id)
388386
if stashed_job is None:
389387
return jsonable_encoder(
390388
{"id": job_id, "status": "FAILED", "error": "Job ID not found"}
@@ -400,7 +398,7 @@ async def _sim_status(self, job_id: str) -> JobOutput:
400398
else:
401399
job_output = await run_job(self.config["handler"], job.__dict__)
402400

403-
job_list.remove(job.id)
401+
get_jobs_progress().remove(job.id)
404402

405403
if job_output.get("error", None):
406404
return jsonable_encoder(

runpod/serverless/modules/rp_job.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,11 @@
1818
from .rp_handler import is_generator
1919
from .rp_http import send_result, stream_result
2020
from .rp_tips import check_return_size
21-
from .worker_state import WORKER_ID, REF_COUNT_ZERO, JobsProgress
21+
from .worker_state import WORKER_ID, REF_COUNT_ZERO, get_jobs_progress
2222

2323
JOB_GET_URL = str(os.environ.get("RUNPOD_WEBHOOK_GET_JOB")).replace("$ID", WORKER_ID)
2424

2525
log = RunPodLogger()
26-
job_progress = JobsProgress()
2726

2827

2928
def _job_get_url(batch_size: int = 1):
@@ -43,7 +42,7 @@ def _job_get_url(batch_size: int = 1):
4342
else:
4443
job_take_url = JOB_GET_URL
4544

46-
job_in_progress = "1" if job_progress.get_job_list() else "0"
45+
job_in_progress = "1" if get_jobs_progress().get_job_list() else "0"
4746
job_take_url += f"&job_in_progress={job_in_progress}"
4847

4948
log.debug(f"rp_job | get_job: {job_take_url}")

runpod/serverless/modules/rp_ping.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,27 @@
1111

1212
from runpod.http_client import SyncClientSession
1313
from runpod.serverless.modules.rp_logger import RunPodLogger
14-
from runpod.serverless.modules.worker_state import WORKER_ID, JobsProgress
14+
from runpod.serverless.modules.worker_state import WORKER_ID, get_jobs_progress
1515
from runpod.version import __version__ as runpod_version
1616

1717
log = RunPodLogger()
18-
jobs = JobsProgress() # Contains the list of jobs that are currently running.
18+
19+
# Lazy loading for Heartbeat instance
20+
_heartbeat_instance = None
21+
22+
23+
def get_heartbeat():
24+
"""Get the global Heartbeat instance with lazy initialization."""
25+
global _heartbeat_instance
26+
if _heartbeat_instance is None:
27+
_heartbeat_instance = Heartbeat()
28+
return _heartbeat_instance
29+
30+
31+
def reset_heartbeat():
32+
"""Reset the lazy-loaded Heartbeat instance (useful for testing)."""
33+
global _heartbeat_instance
34+
_heartbeat_instance = None
1935

2036

2137
class Heartbeat:
@@ -97,7 +113,7 @@ def _send_ping(self):
97113
"""
98114
Sends a heartbeat to the Runpod server.
99115
"""
100-
job_ids = jobs.get_job_list()
116+
job_ids = get_jobs_progress().get_job_list()
101117
ping_params = {"job_id": job_ids, "runpod_version": runpod_version}
102118

103119
try:

runpod/serverless/modules/rp_scale.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,9 @@
1212
from ...http_client import AsyncClientSession, ClientSession, TooManyRequests
1313
from .rp_job import get_job, handle_job
1414
from .rp_logger import RunPodLogger
15-
from .worker_state import JobsProgress, IS_LOCAL_TEST
15+
from .worker_state import IS_LOCAL_TEST, get_jobs_progress
1616

1717
log = RunPodLogger()
18-
job_progress = JobsProgress()
1918

2019

2120
def _handle_uncaught_exception(exc_type, exc_value, exc_traceback):
@@ -149,7 +148,7 @@ def kill_worker(self):
149148

150149
def current_occupancy(self) -> int:
151150
current_queue_count = self.jobs_queue.qsize()
152-
current_progress_count = job_progress.get_job_count()
151+
current_progress_count = get_jobs_progress().get_job_count()
153152

154153
log.debug(
155154
f"JobScaler.status | concurrency: {self.current_concurrency}; queue: {current_queue_count}; progress: {current_progress_count}"
@@ -188,7 +187,7 @@ async def get_jobs(self, session: ClientSession):
188187

189188
for job in acquired_jobs:
190189
await self.jobs_queue.put(job)
191-
job_progress.add(job)
190+
get_jobs_progress().add(job)
192191
log.debug("Job Queued", job["id"])
193192

194193
log.info(f"Jobs in queue: {self.jobs_queue.qsize()}")
@@ -268,6 +267,6 @@ async def handle_job(self, session: ClientSession, job: dict):
268267
self.jobs_queue.task_done()
269268

270269
# Job is no longer in progress
271-
job_progress.remove(job)
270+
get_jobs_progress().remove(job)
272271

273272
log.debug("Finished Job", job["id"])

runpod/serverless/modules/worker_state.py

Lines changed: 40 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,24 @@
1515

1616
log = RunPodLogger()
1717

18+
19+
# ----------------------------- Lazy Loading Utilities -------------------------- #
20+
_jobs_progress_instance = None
21+
22+
23+
def get_jobs_progress():
24+
"""Get the global JobsProgress instance with lazy initialization."""
25+
global _jobs_progress_instance
26+
if _jobs_progress_instance is None:
27+
_jobs_progress_instance = JobsProgress()
28+
return _jobs_progress_instance
29+
30+
31+
def reset_jobs_progress():
32+
"""Reset the lazy-loaded JobsProgress instance (useful for testing)."""
33+
global _jobs_progress_instance
34+
_jobs_progress_instance = None
35+
1836
REF_COUNT_ZERO = time.perf_counter() # Used for benchmarking with the debugger.
1937

2038
WORKER_ID = os.environ.get("RUNPOD_POD_ID", str(uuid.uuid4()))
@@ -72,6 +90,8 @@ class JobsProgress:
7290
_shared_data: Optional[Any] = None
7391
_lock: Optional[Any] = None
7492
_use_multiprocessing: bool = True
93+
_fallback_jobs: list = []
94+
_fallback_lock: Optional[threading.Lock] = None
7595

7696
def __new__(cls):
7797
if cls._instance is None:
@@ -99,10 +119,10 @@ def __repr__(self) -> str:
99119
return f"<{self.__class__.__name__}>: {self.get_job_list()}"
100120

101121
def clear(self) -> None:
102-
if self._use_multiprocessing:
122+
if self._use_multiprocessing and self._lock is not None and self._shared_data is not None:
103123
with self._lock:
104124
self._shared_data['jobs'][:] = []
105-
else:
125+
elif not self._use_multiprocessing and self._fallback_lock is not None:
106126
with self._fallback_lock:
107127
self._fallback_jobs.clear()
108128

@@ -119,12 +139,12 @@ def add(self, element: Any):
119139
else:
120140
raise TypeError("Only Job objects can be added to JobsProgress.")
121141

122-
if self._use_multiprocessing:
142+
if self._use_multiprocessing and self._lock is not None and self._shared_data is not None:
123143
with self._lock:
124144
job_list = self._shared_data['jobs']
125145
if not any(job['id'] == job_dict['id'] for job in job_list):
126146
job_list.append(job_dict)
127-
else:
147+
elif not self._use_multiprocessing and self._fallback_lock is not None:
128148
with self._fallback_lock:
129149
if not any(job['id'] == job_dict['id'] for job in self._fallback_jobs):
130150
self._fallback_jobs.append(job_dict)
@@ -144,13 +164,13 @@ def get(self, element: Any) -> Optional[Job]:
144164
else:
145165
raise TypeError("Only Job objects can be retrieved from JobsProgress.")
146166

147-
if self._use_multiprocessing:
167+
if self._use_multiprocessing and self._lock is not None and self._shared_data is not None:
148168
with self._lock:
149169
for job_dict in self._shared_data['jobs']:
150170
if job_dict['id'] == search_id:
151171
log.debug(f"JobsProgress | Retrieved job: {job_dict['id']}")
152172
return Job(**job_dict)
153-
else:
173+
elif not self._use_multiprocessing and self._fallback_lock is not None:
154174
with self._fallback_lock:
155175
for job_dict in self._fallback_jobs:
156176
if job_dict['id'] == search_id:
@@ -171,15 +191,15 @@ def remove(self, element: Any):
171191
else:
172192
raise TypeError("Only Job objects can be removed from JobsProgress.")
173193

174-
if self._use_multiprocessing:
194+
if self._use_multiprocessing and self._lock is not None and self._shared_data is not None:
175195
with self._lock:
176196
job_list = self._shared_data['jobs']
177197
for i, job_dict in enumerate(job_list):
178198
if job_dict['id'] == job_id:
179199
del job_list[i]
180200
log.debug(f"JobsProgress | Removed job: {job_dict['id']}")
181201
break
182-
else:
202+
elif not self._use_multiprocessing and self._fallback_lock is not None:
183203
with self._fallback_lock:
184204
for i, job_dict in enumerate(self._fallback_jobs):
185205
if job_dict['id'] == job_id:
@@ -191,10 +211,11 @@ def get_job_list(self) -> Optional[str]:
191211
"""
192212
Returns the list of job IDs as comma-separated string.
193213
"""
194-
if self._use_multiprocessing:
214+
job_list = []
215+
if self._use_multiprocessing and self._lock is not None and self._shared_data is not None:
195216
with self._lock:
196217
job_list = list(self._shared_data['jobs'])
197-
else:
218+
elif not self._use_multiprocessing and self._fallback_lock is not None:
198219
with self._fallback_lock:
199220
job_list = list(self._fallback_jobs)
200221

@@ -208,19 +229,21 @@ def get_job_count(self) -> int:
208229
"""
209230
Returns the number of jobs.
210231
"""
211-
if self._use_multiprocessing:
232+
if self._use_multiprocessing and self._lock is not None and self._shared_data is not None:
212233
with self._lock:
213234
return len(self._shared_data['jobs'])
214-
else:
235+
elif not self._use_multiprocessing and self._fallback_lock is not None:
215236
with self._fallback_lock:
216237
return len(self._fallback_jobs)
238+
return 0
217239

218240
def __iter__(self):
219241
"""Make the class iterable - returns Job objects"""
220-
if self._use_multiprocessing:
242+
job_dicts = []
243+
if self._use_multiprocessing and self._lock is not None and self._shared_data is not None:
221244
with self._lock:
222245
job_dicts = list(self._shared_data['jobs'])
223-
else:
246+
elif not self._use_multiprocessing and self._fallback_lock is not None:
224247
with self._fallback_lock:
225248
job_dicts = list(self._fallback_jobs)
226249
return iter(Job(**job_dict) for job_dict in job_dicts)
@@ -240,9 +263,10 @@ def __contains__(self, element: Any) -> bool:
240263
else:
241264
return False
242265

243-
if self._use_multiprocessing:
266+
if self._use_multiprocessing and self._lock is not None and self._shared_data is not None:
244267
with self._lock:
245268
return any(job['id'] == search_id for job in self._shared_data['jobs'])
246-
else:
269+
elif not self._use_multiprocessing and self._fallback_lock is not None:
247270
with self._fallback_lock:
248271
return any(job['id'] == search_id for job in self._fallback_jobs)
272+
return False

runpod/serverless/worker.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@
88
from typing import Any, Dict
99

1010
from runpod.serverless.modules import rp_logger, rp_local, rp_ping, rp_scale
11+
from runpod.serverless.modules.rp_ping import get_heartbeat
1112

1213
log = rp_logger.RunPodLogger()
13-
heartbeat = rp_ping.Heartbeat()
1414

1515

1616
def _is_local(config) -> bool:
@@ -36,7 +36,7 @@ def run_worker(config: Dict[str, Any]) -> None:
3636
config (Dict[str, Any]): Configuration parameters for the worker.
3737
"""
3838
# Start pinging RunPod to show that the worker is alive.
39-
heartbeat.start_ping()
39+
get_heartbeat().start_ping()
4040

4141
# Create a JobScaler responsible for adjusting the concurrency
4242
job_scaler = rp_scale.JobScaler(config)

tests/test_cli/test_cli_sanity.py

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -20,20 +20,18 @@ def cli_runner():
2020
@pytest.fixture(autouse=True)
2121
def reset_jobs_progress():
2222
"""Reset JobsProgress state before each test."""
23+
yield
24+
# Cleanup after test
2325
try:
24-
from runpod.serverless.modules.worker_state import JobsProgress
25-
JobsProgress._instance = None
26-
yield
27-
# Cleanup after test
28-
if hasattr(JobsProgress, '_instance') and JobsProgress._instance:
29-
try:
30-
JobsProgress._instance.clear()
31-
except Exception:
32-
pass
33-
JobsProgress._instance = None
34-
except ImportError:
35-
# JobsProgress might not be available in all test contexts
36-
yield
26+
from runpod.serverless.modules.worker_state import reset_jobs_progress, JobsProgress
27+
from runpod.serverless.modules.rp_ping import reset_heartbeat
28+
reset_jobs_progress()
29+
reset_heartbeat()
30+
# Also reset the singleton instance
31+
if hasattr(JobsProgress, '_instance'):
32+
JobsProgress._instance = None
33+
except (ImportError, AttributeError):
34+
pass
3735

3836

3937
class TestCLISanity:

0 commit comments

Comments
 (0)