@@ -96,6 +96,7 @@ class IOContext:
9696
9797 _cancel_issued : bool = False
9898 _cancel_callback : Callable [[], None ] | None = None
99+ _exited : bool = False
99100
100101 def __init__ (
101102 self ,
@@ -889,25 +890,35 @@ async def _generate_inputs(
889890 if item .kill_switch :
890891 logger .debug (f"Task { self .task_id } input kill signal input." )
891892 return
892- inputs .append (
893- (
894- item .input_id ,
895- item .retry_count ,
896- item .function_call_id ,
897- item .attempt_token ,
898- item .input ,
899- item .function_call_invocation_type ,
893+ live_io_context = self .current_inputs .get (item .input_id )
894+ if live_io_context is not None and (
895+ live_io_context .retry_counts [live_io_context .input_ids .index (item .input_id )]
896+ == item .retry_count
897+ ):
898+ # An expired fetch lease redelivers a still-running attempt under the same
899+ # input id and retry count; a retry of a failed attempt gets a new retry count.
900+ logger .warning (f"Skipping duplicate delivery of input { item .input_id } " )
901+ else :
902+ inputs .append (
903+ (
904+ item .input_id ,
905+ item .retry_count ,
906+ item .function_call_id ,
907+ item .attempt_token ,
908+ item .input ,
909+ item .function_call_invocation_type ,
910+ )
900911 )
901- )
902912 if item .input .final_input :
903913 if request .batch_max_size > 0 :
904914 logger .debug (f"Task { self .task_id } Final input not expected in batch input stream" )
905915 final_input_received = True
906916 break
907917
908- # If yielded, allow input slots to be released via exit_context
909- yield inputs
910- yielded = True
918+ if inputs :
919+ # If yielded, allow input slots to be released via exit_context
920+ yield inputs
921+ yielded = True
911922
912923 # TODO(michael): Remove use of max_inputs after worker rollover
913924 single_use_container = self .function_def .single_use_containers or self .function_def .max_inputs == 1
@@ -950,7 +961,7 @@ async def run_inputs_outputs(
950961 # collect all active input slots, meaning all inputs have wrapped up.
951962 await self ._input_slots .close ()
952963
953- async def _send_outputs (self , outputs : list [api_pb2 .FunctionPutOutputsItem ]) -> None :
964+ async def _send_outputs (self , io_context : IOContext , outputs : list [api_pb2 .FunctionPutOutputsItem ]) -> None :
954965 """Send pre-built output items with retry and chunking."""
955966 # There are multiple outputs for a single IOContext in the case of @modal.batched.
956967 # Limit the batch size to 20 to stay within message size limits and buffer size limits.
@@ -963,8 +974,7 @@ async def _send_outputs(self, outputs: list[api_pb2.FunctionPutOutputsItem]) ->
963974 max_retries = None , # Retry indefinitely, trying every 1s.
964975 ),
965976 )
966- input_ids = [output .input_id for output in outputs ]
967- self .exit_context (input_ids )
977+ self .exit_context (io_context )
968978
969979 @asynccontextmanager
970980 async def handle_input_exception (
@@ -984,7 +994,7 @@ async def handle_input_exception(
984994 raise
985995 except (InputCancellation , asyncio .CancelledError ):
986996 outputs = await io_context .output_items_cancellation (started_at )
987- await self ._send_outputs (outputs )
997+ await self ._send_outputs (io_context , outputs )
988998 logger .warning (f"Successfully canceled input { io_context .input_ids } " )
989999 return
9901000 except BaseException as exc :
@@ -995,13 +1005,24 @@ async def handle_input_exception(
9951005 # print exception so it's logged
9961006 print_exception (* sys .exc_info ())
9971007 outputs = await io_context .output_items_exception (started_at , self .task_id , exc )
998- await self ._send_outputs (outputs )
1008+ await self ._send_outputs (io_context , outputs )
9991009
1000- def exit_context (self , input_ids : list [str ]):
1001- for input_id in input_ids :
1002- self .current_inputs .pop (input_id )
1003-
1004- self ._input_slots .release ()
1010+ def exit_context (self , io_context : IOContext ):
1011+ # A cancellation can land after outputs were already sent, re-entering here for the same
1012+ # context; exit at most once so the input slot is not released twice.
1013+ if io_context ._exited :
1014+ return
1015+ io_context ._exited = True
1016+ try :
1017+ for input_id in io_context .input_ids :
1018+ # A retry admitted before its predecessor finished exiting takes over the
1019+ # tracking entry, so remove it only if this context still owns it.
1020+ if self .current_inputs .get (input_id ) is io_context :
1021+ self .current_inputs .pop (input_id )
1022+ else :
1023+ logger .warning (f"Input { input_id } missing from active input tracking" )
1024+ finally :
1025+ self ._input_slots .release ()
10051026
10061027 # skip inspection of user-generated output_data for synchronicity input translation
10071028 @synchronizer .no_io_translation
@@ -1013,7 +1034,7 @@ async def push_outputs(
10131034 ) -> None :
10141035 # The standard output encoding+sending method for successful function outputs
10151036 outputs = await io_context .output_items (started_at , output_data )
1016- await self ._send_outputs (outputs )
1037+ await self ._send_outputs (io_context , outputs )
10171038
10181039 @asynccontextmanager
10191040 async def snapshot_context_manager (self ) -> AsyncGenerator [None , None ]:
0 commit comments