1313from dataclasses import dataclass
1414from typing import TYPE_CHECKING , Any
1515
16+ from spooled ._version import __version__
1617from spooled .types .jobs import ClaimedJob
1718from spooled .worker .types import (
1819 AsyncJobContext ,
@@ -75,7 +76,7 @@ def __init__(
7576 shutdown_timeout : float = 30.0 ,
7677 hostname : str | None = None ,
7778 worker_type : str = "python" ,
78- version : str = "1.0.0" ,
79+ version : str = __version__ ,
7980 metadata : dict [str , Any ] | None = None ,
8081 ) -> None :
8182 """
@@ -116,6 +117,7 @@ def __init__(
116117 self ._worker_heartbeat_task : asyncio .Task [None ] | None = None
117118 self ._shutdown_event : asyncio .Event | None = None
118119 self ._semaphore : asyncio .Semaphore | None = None
120+ self ._active_jobs_lock = asyncio .Lock ()
119121
120122 # Event handlers
121123 self ._event_handlers : dict [WorkerEvent , list [Callable [..., Any ]]] = {}
@@ -289,7 +291,7 @@ async def stop(self) -> None:
289291 for job_id , active in list (self ._active_jobs .items ()):
290292 if self ._debug :
291293 self ._debug (f"Force-failing job { job_id } due to shutdown timeout" , None )
292- await self ._fail_job (active . job , "Worker shutdown timeout" )
294+ await self ._fail_job (active , "Worker shutdown timeout" )
293295
294296 # Deregister worker
295297 if self ._worker_id :
@@ -352,14 +354,15 @@ async def _process_job(self, job: ClaimedJob) -> None:
352354 abort_event = abort_event ,
353355 )
354356
355- self ._active_jobs [job .id ] = active
357+ async with self ._active_jobs_lock :
358+ self ._active_jobs [job .id ] = active
356359
357360 # Start per-job heartbeat
358361 heartbeat_interval = (
359362 self ._options .lease_duration * self ._options .heartbeat_fraction
360363 )
361364 active .heartbeat_task = asyncio .create_task (
362- self ._job_heartbeat_loop (job . id , heartbeat_interval )
365+ self ._job_heartbeat_loop (active , heartbeat_interval )
363366 )
364367
365368 # Execute handler
@@ -394,7 +397,7 @@ async def _execute_handler(self, active: ActiveJob) -> None:
394397 return
395398
396399 # Complete the job
397- await self ._complete_job (job , result )
400+ await self ._complete_job (active , result )
398401
399402 except Exception as e :
400403 # Check if aborted
@@ -404,25 +407,30 @@ async def _execute_handler(self, active: ActiveJob) -> None:
404407 return
405408
406409 error_message = str (e )
407- await self ._fail_job (job , error_message )
410+ await self ._fail_job (active , error_message )
408411
409412 finally :
410- await self ._cleanup_job (job .id )
411-
412- async def _complete_job (self , job : ClaimedJob , result : dict [str , Any ] | None ) -> None :
413- """Complete a job."""
414- if not self ._worker_id :
415- return
413+ await self ._cleanup_job (active )
416414
415+ async def _complete_job (self , active : ActiveJob , result : dict [str , Any ] | None ) -> None :
416+ """Complete an active execution only while it still owns the job slot."""
417+ job = active .job
417418 try :
419+ async with self ._active_jobs_lock :
420+ worker_id = self ._worker_id
421+ if self ._active_jobs .get (job .id ) is not active or not worker_id :
422+ return
418423 params : dict [str , Any ] = {
419- "worker_id" : self . _worker_id ,
424+ "worker_id" : worker_id ,
420425 "result" : result ,
421426 }
422427 if job .lease_id is not None :
423428 params ["lease_id" ] = job .lease_id
424429 await self ._client .jobs .complete (job .id , params )
425430
431+ async with self ._active_jobs_lock :
432+ if self ._active_jobs .get (job .id ) is not active :
433+ return
426434 self ._emit ("job:completed" , JobCompletedEventData (
427435 job_id = job .id ,
428436 queue_name = job .queue_name ,
@@ -433,22 +441,27 @@ async def _complete_job(self, job: ClaimedJob, result: dict[str, Any] | None) ->
433441 if self ._debug :
434442 self ._debug (f"Failed to complete job { job .id } : { e } " , None )
435443
436- async def _fail_job (self , job : ClaimedJob , error_message : str ) -> None :
437- """Fail a job."""
438- if not self ._worker_id :
439- return
440-
444+ async def _fail_job (self , active : ActiveJob , error_message : str ) -> None :
445+ """Fail an active execution only while it still owns the job slot."""
446+ job = active .job
441447 will_retry = job .retry_count < job .max_retries
442448
443449 try :
450+ async with self ._active_jobs_lock :
451+ worker_id = self ._worker_id
452+ if self ._active_jobs .get (job .id ) is not active or not worker_id :
453+ return
444454 params : dict [str , Any ] = {
445- "worker_id" : self . _worker_id ,
455+ "worker_id" : worker_id ,
446456 "error" : error_message ,
447457 }
448458 if job .lease_id is not None :
449459 params ["lease_id" ] = job .lease_id
450460 await self ._client .jobs .fail (job .id , params )
451461
462+ async with self ._active_jobs_lock :
463+ if self ._active_jobs .get (job .id ) is not active :
464+ return
452465 self ._emit ("job:failed" , JobFailedEventData (
453466 job_id = job .id ,
454467 queue_name = job .queue_name ,
@@ -460,36 +473,42 @@ async def _fail_job(self, job: ClaimedJob, error_message: str) -> None:
460473 if self ._debug :
461474 self ._debug (f"Failed to fail job { job .id } : { e } " , None )
462475
463- async def _cleanup_job (self , job_id : str ) -> None :
464- """Clean up after job completion."""
465- active = self ._active_jobs .pop (job_id , None )
466- if active and active .heartbeat_task :
476+ async def _cleanup_job (self , active : ActiveJob ) -> None :
477+ """Clean up only the exact execution that finished."""
478+ async with self ._active_jobs_lock :
479+ if self ._active_jobs .get (active .job .id ) is active :
480+ del self ._active_jobs [active .job .id ]
481+ if active .heartbeat_task and active .heartbeat_task is not asyncio .current_task ():
467482 active .heartbeat_task .cancel ()
468483 try :
469484 await active .heartbeat_task
470485 except asyncio .CancelledError :
471486 pass
472487
473- async def _job_heartbeat_loop (self , job_id : str , interval : float ) -> None :
474- """Job heartbeat loop."""
475- while job_id in self ._active_jobs and self ._worker_id :
488+ async def _job_heartbeat_loop (self , active : ActiveJob , interval : float ) -> None :
489+ """Send heartbeats bound to one immutable execution."""
490+ job = active .job
491+ while True :
476492 await asyncio .sleep (interval )
477493
478- active = self ._active_jobs .get (job_id )
479- if active is None or not self ._worker_id :
480- break
481-
482494 try :
495+ async with self ._active_jobs_lock :
496+ worker_id = self ._worker_id
497+ if self ._active_jobs .get (job .id ) is not active or not worker_id :
498+ break
483499 params : dict [str , Any ] = {
484- "worker_id" : self . _worker_id ,
500+ "worker_id" : worker_id ,
485501 "lease_duration_secs" : self ._options .lease_duration ,
486502 }
487- if active .job .lease_id is not None :
488- params ["lease_id" ] = active .job .lease_id
489- await self ._client .jobs .heartbeat (job_id , params )
503+ if job .lease_id is not None :
504+ params ["lease_id" ] = job .lease_id
505+ await self ._client .jobs .heartbeat (job .id , params )
506+ async with self ._active_jobs_lock :
507+ if self ._active_jobs .get (job .id ) is not active :
508+ break
490509 except Exception as e :
491510 if self ._debug :
492- self ._debug (f"Job heartbeat failed for { job_id } : { e } " , None )
511+ self ._debug (f"Job heartbeat failed for { job . id } : { e } " , None )
493512
494513 async def _worker_heartbeat_loop (self , interval : float ) -> None :
495514 """Worker heartbeat loop."""
0 commit comments