Skip to content

Commit 71e8f96

Browse files
parthivapsanimodal-bot
authored andcommitted
Drop duplicate input deliveries in the container (#49117)
GitOrigin-RevId: 80b19412ff275d7003fbabb1ab357fb71297bc58
1 parent 84c6f3d commit 71e8f96

3 files changed

Lines changed: 100 additions & 25 deletions

File tree

py/modal/_container_entrypoint.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ async def run_input_async(io_context: IOContext) -> None:
145145
item_count += 1
146146

147147
await container_io_manager._send_outputs.aio(
148-
io_context.output_items_generator_done(started_at, item_count)
148+
io_context, io_context.output_items_generator_done(started_at, item_count)
149149
)
150150
else:
151151
value = await io_context.call_function_async()
@@ -182,7 +182,9 @@ def run_input_sync(io_context: IOContext) -> None:
182182
container_io_manager._queue_put(generator_queue, value)
183183
item_count += 1
184184

185-
container_io_manager._send_outputs(io_context.output_items_generator_done(started_at, item_count))
185+
container_io_manager._send_outputs(
186+
io_context, io_context.output_items_generator_done(started_at, item_count)
187+
)
186188
else:
187189
values = io_context.call_function_sync()
188190
container_io_manager.push_outputs(io_context, started_at, values)

py/modal/_runtime/container_io_manager.py

Lines changed: 44 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -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]:

py/test/container_test.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2157,6 +2157,58 @@ async def _func(x):
21572157
assert not triggered_assertions
21582158

21592159

2160+
def test_container_io_manager_drops_duplicate_input_delivery(client, servicer):
2161+
dummy_container_args = api_pb2.ContainerArguments(
2162+
function_id="fu-123", function_def=api_pb2.Function(target_concurrent_inputs=2)
2163+
)
2164+
from modal._utils.async_utils import synchronizer
2165+
2166+
io_manager = ContainerIOManager(dummy_container_args, client)
2167+
_io_manager = synchronizer._translate_in(io_manager)
2168+
2169+
async def _func(x):
2170+
await asyncio.sleep(x)
2171+
2172+
fin_func = FinalizedFunction(
2173+
_func, is_async=True, is_generator=False, supported_output_formats=[api_pb2.DATA_FORMAT_PICKLE]
2174+
)
2175+
2176+
duplicate_input_id = "in-xyz0"
2177+
servicer.container_inputs = _get_inputs(((42,), {}), n=4)
2178+
servicer.container_inputs[1].inputs[0].input_id = duplicate_input_id # duplicate of a running input
2179+
servicer.container_inputs[2].inputs[0].input_id = duplicate_input_id # retry of the running input
2180+
servicer.container_inputs[2].inputs[0].retry_count = 1
2181+
servicer.container_inputs[3].inputs[0].input_id = duplicate_input_id # redelivery after completion
2182+
yielded: list[IOContext] = []
2183+
2184+
for io_context in io_manager.run_inputs_outputs(finalized_functions={"": fin_func}):
2185+
yielded.append(io_context)
2186+
if len(yielded) == 2:
2187+
# The same-id same-retry-count duplicate was dropped and its slot freed, while the
2188+
# same-id delivery with a new retry count is a genuine retry and runs.
2189+
assert yielded[0].input_ids == (duplicate_input_id,)
2190+
assert yielded[0].retry_counts == (0,)
2191+
assert yielded[1].input_ids == (duplicate_input_id,)
2192+
assert yielded[1].retry_counts == (1,)
2193+
io_manager.push_outputs(yielded[0], started_at=0.0, output_data=[None])
2194+
# The predecessor's exit must not evict the retry's tracking entry (cancel routing).
2195+
assert _io_manager.current_inputs == {duplicate_input_id: yielded[1]}
2196+
io_manager.push_outputs(yielded[1], started_at=0.0, output_data=[None])
2197+
assert duplicate_input_id not in _io_manager.current_inputs
2198+
elif len(yielded) == 3:
2199+
# Once the first copy completed, a redelivery of the same input id runs again.
2200+
assert io_context.input_ids == (duplicate_input_id,)
2201+
assert io_context.retry_counts == (0,)
2202+
io_manager.push_outputs(yielded[2], started_at=0.0, output_data=[None])
2203+
# A repeated exit for an already-exited context must not release its slot twice.
2204+
active_after = _io_manager._input_slots.active
2205+
io_manager.exit_context(yielded[2])
2206+
assert _io_manager._input_slots.active == active_after
2207+
2208+
assert len(yielded) == 3
2209+
assert not _io_manager.current_inputs
2210+
2211+
21602212
@pytest.mark.asyncio
21612213
async def test_input_slots():
21622214
slots = InputSlots(10)

0 commit comments

Comments
 (0)