modAL 1.5.2.dev22__py3-none-any.whl → 1.5.3.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -164,6 +164,9 @@ RETRYABLE_GRPC_STATUS_CODES = [
164
164
  ]
165
165
  SERVER_RETRY_WARNING_TIME_INTERVAL = 30.0
166
166
  DEFAULT_MAX_RETRIES = 3
167
+ GRPC_KEEPALIVE_TIME_SECS = 30.0
168
+ GRPC_KEEPALIVE_TIMEOUT_SECS = 10.0
169
+ GRPC_WINDOW_SIZE = 64 * 1024 * 1024 # 64 MiB
167
170
 
168
171
 
169
172
  @dataclass
@@ -251,6 +254,21 @@ class CustomProtoStatusDetailsCodec(StatusDetailsCodecBase):
251
254
  custom_detail_codec = CustomProtoStatusDetailsCodec()
252
255
 
253
256
 
257
+ def create_channel_config() -> grpclib.config.Configuration:
258
+ """Shared grpclib channel settings for Modal client connections.
259
+
260
+ HTTP/2 keepalive probes keep idle transports warm through stateful
261
+ middleboxes and surface dead connections before retries reuse them.
262
+ """
263
+ return grpclib.config.Configuration(
264
+ _keepalive_time=GRPC_KEEPALIVE_TIME_SECS,
265
+ _keepalive_timeout=GRPC_KEEPALIVE_TIMEOUT_SECS,
266
+ _keepalive_permit_without_calls=True,
267
+ http2_connection_window_size=GRPC_WINDOW_SIZE,
268
+ http2_stream_window_size=GRPC_WINDOW_SIZE,
269
+ )
270
+
271
+
254
272
  def create_channel(
255
273
  server_url: str,
256
274
  metadata: dict[str, str] = {},
@@ -262,10 +280,7 @@ def create_channel(
262
280
  o = urllib.parse.urlparse(server_url)
263
281
 
264
282
  channel: grpclib.client.Channel
265
- config = grpclib.config.Configuration(
266
- http2_connection_window_size=64 * 1024 * 1024, # 64 MiB
267
- http2_stream_window_size=64 * 1024 * 1024, # 64 MiB
268
- )
283
+ config = create_channel_config()
269
284
 
270
285
  if o.scheme == "unix":
271
286
  channel = grpclib.client.Channel(path=o.path, config=config, status_details_codec=custom_detail_codec)
@@ -1,7 +1,6 @@
1
1
  # Copyright Modal Labs 2025
2
2
  import asyncio
3
3
  import base64
4
- import io
5
4
  import json
6
5
  import socket
7
6
  import ssl
@@ -9,36 +8,24 @@ import time
9
8
  import typing
10
9
  import urllib.parse
11
10
  import weakref
12
- from collections.abc import AsyncGenerator, AsyncIterable, Callable
11
+ from collections.abc import AsyncGenerator, Callable
13
12
  from contextlib import suppress
14
- from typing import BinaryIO, TypeVar
13
+ from typing import TypeVar
15
14
 
16
15
  import grpclib.client
17
- import grpclib.config
18
16
  from grpclib import GRPCError, Status
19
17
  from grpclib.exceptions import StreamTerminatedError
20
18
 
21
19
  from modal.config import logger
22
- from modal.exception import AuthError, ExecTimeoutError, InternalError, ServiceError, TimeoutError as ModalTimeoutError
20
+ from modal.exception import ExecTimeoutError, TimeoutError as ModalTimeoutError
23
21
  from modal_proto import api_pb2, task_command_router_pb2 as sr_pb2
24
22
  from modal_proto.task_command_router_grpc import TaskCommandRouterStub
25
23
 
26
24
  from .._grpc_client import grpc_error_converter
27
- from .._utils.grpc_utils import ModalChannel
25
+ from .._utils.grpc_utils import ModalChannel, create_channel_config
28
26
  from .async_utils import aclosing, retry
29
27
  from .grpc_utils import RETRYABLE_GRPC_STATUS_CODES
30
28
 
31
- STREAMING_STDIN_CHUNK_SIZE = 256 * 1024
32
-
33
- _STREAMING_STDIN_RESUMABLE_EXCEPTIONS = (
34
- AuthError,
35
- InternalError,
36
- ServiceError,
37
- StreamTerminatedError,
38
- OSError,
39
- asyncio.TimeoutError,
40
- )
41
-
42
29
 
43
30
  @retry(n_attempts=34, base_delay=1, max_delay=10, attempt_timeout=10, total_timeout=310)
44
31
  async def _connect_channel(channel: grpclib.client.Channel):
@@ -234,10 +221,7 @@ class TaskCommandRouterClient:
234
221
  host,
235
222
  port,
236
223
  ssl=ssl_context,
237
- config=grpclib.config.Configuration(
238
- http2_connection_window_size=64 * 1024 * 1024, # 64 MiB
239
- http2_stream_window_size=64 * 1024 * 1024, # 64 MiB
240
- ),
224
+ config=create_channel_config(),
241
225
  closed_error_message="Unable to perform operation on a detached sandbox",
242
226
  )
243
227
 
@@ -484,118 +468,6 @@ class TaskCommandRouterClient:
484
468
  lambda: self._call_with_auth_retry(self._stub.TaskExecStdinWrite, request)
485
469
  )
486
470
 
487
- async def exec_stdin_write_stream(
488
- self,
489
- task_id: str,
490
- exec_id: str,
491
- source: BinaryIO,
492
- chunk_size: int = STREAMING_STDIN_CHUNK_SIZE,
493
- max_resume_attempts: int = 9,
494
- ) -> int:
495
- """Stream `source` into the exec's stdin, with bounded resume on transient failures.
496
-
497
- Streams the full contents of `source` in one client-streaming RPC and
498
- closes stdin (EOF) on success. On a resumable error, queries
499
- `exec_stdin_status` for the server's canonical offset, seeks `source`
500
- to that point, and reopens the stream.
501
-
502
- Args:
503
- task_id: The task ID of the task running the exec'd command.
504
- exec_id: The execution ID of the command to write to.
505
- source: A seekable byte source (file, BytesIO, etc.).
506
- chunk_size: Bytes per outbound message.
507
- max_resume_attempts: Upper bound on resume retries before giving up.
508
-
509
- Returns:
510
- The total number of bytes streamed into the exec's stdin.
511
- """
512
- offset = 0
513
- attempt = 0
514
- while True:
515
-
516
- async def _chunks():
517
- while True:
518
- chunk = source.read(chunk_size)
519
- if not chunk:
520
- return
521
- yield chunk
522
-
523
- try:
524
- source.seek(offset)
525
- await self._exec_stdin_write_stream(task_id, exec_id, offset, _chunks())
526
- return source.tell()
527
- except (*_STREAMING_STDIN_RESUMABLE_EXCEPTIONS, AttributeError) as e:
528
- # StreamTerminatedError surfaces as AttributeError in grpclib<=0.4.7;
529
- # see call_with_retries_on_transient_errors.
530
- if isinstance(e, AttributeError) and "_write_appdata" not in str(e):
531
- raise
532
- attempt += 1
533
- if attempt > max_resume_attempts:
534
- raise
535
- status = await self.exec_stdin_status(task_id=task_id, exec_id=exec_id)
536
- if status.closed:
537
- pos = source.tell()
538
- if status.num_bytes_written == pos and source.seek(0, io.SEEK_END) == pos:
539
- logger.debug(f"exec_stdin_write_stream completed but response was lost: {e}")
540
- return pos
541
- raise
542
- offset = status.num_bytes_written
543
- logger.debug(f"exec_stdin_write_stream resuming from offset {offset} after error: {e}")
544
-
545
- async def _exec_stdin_write_stream(
546
- self,
547
- task_id: str,
548
- exec_id: str,
549
- start_offset: int,
550
- chunks: AsyncIterable[bytes],
551
- ) -> sr_pb2.TaskExecStdinWriteStreamResponse | None:
552
- """Single client-streaming attempt: Start, Data chunks, then End (EOF).
553
-
554
- Does not retry; `exec_stdin_write_stream` owns resume.
555
- """
556
- with grpc_error_converter():
557
- stream = self._stub.TaskExecStdinWriteStream.open(metadata=self._get_metadata())
558
- async with stream as s:
559
- start = sr_pb2.TaskExecStdinWriteStreamRequest(
560
- start=sr_pb2.TaskExecStdinWriteStreamStart(
561
- task_id=task_id,
562
- exec_id=exec_id,
563
- offset=start_offset,
564
- ),
565
- )
566
- await s.send_message(start)
567
- async for data in chunks:
568
- if not data:
569
- continue
570
- await s.send_message(sr_pb2.TaskExecStdinWriteStreamRequest(data=data))
571
- # The server closes stdin only on this explicit End message. A
572
- # stream that breaks before it leaves stdin open for resume.
573
- await s.send_message(
574
- sr_pb2.TaskExecStdinWriteStreamRequest(end=sr_pb2.TaskExecStdinWriteStreamEnd()),
575
- end=True,
576
- )
577
- return await s.recv_message()
578
-
579
- async def exec_stdin_status(self, task_id: str, exec_id: str) -> sr_pb2.TaskExecStdinStatusResponse:
580
- """Read the current stdin write status for an exec'd command, to support retries from the right offset.
581
-
582
- Used by streaming clients to find the resume offset after a stream
583
- failure. Evicts any in-flight stdin stream for the exec, so it is not
584
- safe to call for read-only observability.
585
-
586
- Args:
587
- task_id: The task ID of the task running the exec'd command.
588
- exec_id: The execution ID of the command to query.
589
- Raises:
590
- Other errors: If retries are exhausted on transient errors or if there's an error
591
- from the RPC itself.
592
- """
593
- request = sr_pb2.TaskExecStdinStatusRequest(task_id=task_id, exec_id=exec_id)
594
- with grpc_error_converter():
595
- return await call_with_retries_on_transient_errors(
596
- lambda: self._call_with_auth_retry(self._stub.TaskExecStdinStatus, request)
597
- )
598
-
599
471
  async def sandbox_stdin_write_v2(
600
472
  self, task_id: str, offset: int, data: bytes, eof: bool
601
473
  ) -> sr_pb2.SandboxStdinWriteV2Response:
@@ -625,16 +497,7 @@ class TaskCommandRouterClient:
625
497
  TimeoutError: If the sandbox does not become ready within `timeout`.
626
498
  """
627
499
  request = sr_pb2.SandboxWaitUntilReadyTcrRequest(task_id=task_id, timeout=timeout)
628
- with grpc_error_converter():
629
- try:
630
- return await asyncio.wait_for(
631
- call_with_retries_on_transient_errors(
632
- lambda: self._call_with_auth_retry(self._stub.SandboxWaitUntilReady, request, timeout=timeout),
633
- ),
634
- timeout=timeout,
635
- )
636
- except asyncio.TimeoutError:
637
- raise ModalTimeoutError("Timeout expired")
500
+ return await self._unary_call_with_deadline(self._stub.SandboxWaitUntilReady, request, timeout=timeout)
638
501
 
639
502
  async def exec_poll(self, task_id: str, exec_id: str, deadline: float | None = None) -> sr_pb2.TaskExecPollResponse:
640
503
  """Poll for the exit status of an exec'd command, properly retrying on transient errors.
@@ -944,21 +807,14 @@ class TaskCommandRouterClient:
944
807
  window, the call is cancelled and a `modal.exception.TimeoutError` is raised.
945
808
  """
946
809
  request = sr_pb2.TaskReloadVolumesRequest(task_id=task_id)
947
- with grpc_error_converter():
948
- try:
949
- return await asyncio.wait_for(
950
- call_with_retries_on_transient_errors(
951
- lambda: self._call_with_auth_retry(self._stub.TaskReloadVolumes, request, timeout=timeout),
952
- ),
953
- timeout=timeout,
954
- )
955
- except asyncio.TimeoutError:
956
- raise ModalTimeoutError("Timeout expired")
810
+ return await self._unary_call_with_deadline(self._stub.TaskReloadVolumes, request, timeout=timeout)
811
+
812
+ async def _unary_call_with_deadline(self, rpc, request, *, timeout: float, **kwargs):
813
+ """Call a unary RPC with transient-error retries bounded by a total deadline.
957
814
 
958
- async def _snapshot_with_deadline(self, rpc, request, *, timeout: float, **kwargs):
959
- # helper method for snapshot_directory and snapshot_filesystem to handle grpc
960
- # deadlines in a consistent way, converting any error to TimeoutError after passing
961
- # the total deadline budget
815
+ Raises `modal.exception.TimeoutError` once the deadline has elapsed, or when
816
+ the server responds with `DEADLINE_EXCEEDED`.
817
+ """
962
818
  timeout_deadline = time.monotonic() + timeout
963
819
 
964
820
  def call():
@@ -976,17 +832,21 @@ class TaskCommandRouterClient:
976
832
  exclude_status_codes=[Status.DEADLINE_EXCEEDED, Status.CANCELLED],
977
833
  timeout_deadline=timeout_deadline,
978
834
  )
979
- except Exception:
980
- if time.monotonic() >= timeout_deadline:
835
+ except Exception as exc:
836
+ if time.monotonic() >= timeout_deadline or getattr(exc, "_grpc_status", None) == Status.DEADLINE_EXCEEDED:
981
837
  raise ModalTimeoutError("Timeout expired")
982
838
  raise
983
839
 
984
840
  async def snapshot_directory(
985
841
  self, request: sr_pb2.TaskSnapshotDirectoryRequest, *, timeout: float, **kwargs
986
842
  ) -> sr_pb2.TaskSnapshotDirectoryResponse:
987
- return await self._snapshot_with_deadline(self._stub.TaskSnapshotDirectory, request, timeout=timeout, **kwargs)
843
+ return await self._unary_call_with_deadline(
844
+ self._stub.TaskSnapshotDirectory, request, timeout=timeout, **kwargs
845
+ )
988
846
 
989
847
  async def snapshot_filesystem(
990
848
  self, request: sr_pb2.TaskSnapshotFilesystemRequest, *, timeout: float, **kwargs
991
849
  ) -> sr_pb2.TaskSnapshotFilesystemResponse:
992
- return await self._snapshot_with_deadline(self._stub.TaskSnapshotFilesystem, request, timeout=timeout, **kwargs)
850
+ return await self._unary_call_with_deadline(
851
+ self._stub.TaskSnapshotFilesystem, request, timeout=timeout, **kwargs
852
+ )
modal/cli/changelog.py CHANGED
@@ -120,17 +120,17 @@ def changelog(
120
120
 
121
121
  Examples:
122
122
 
123
- ```bash
124
- modal changelog --since 1.2.0 # Show updates added after a specific version
123
+ ```bash
124
+ modal changelog --since 1.2.0 # Show updates added after a specific version
125
125
 
126
- modal changelog --since 2026-01-01 # Show updates added after a specific date
126
+ modal changelog --since 2026-01-01 # Show updates added after a specific date
127
127
 
128
- modal changelog --newer # Show updates released after the currently installed version
128
+ modal changelog --newer # Show updates released after the currently installed version
129
129
 
130
- modal changelog --last 3 # Show updates included in the 3 most recent releases
130
+ modal changelog --last 3 # Show updates included in the 3 most recent releases
131
131
 
132
- modal changelog --for 1.3.1 # Show the changelog for a specific release
133
- ```
132
+ modal changelog --for 1.3.1 # Show the changelog for a specific release
133
+ ```
134
134
 
135
135
  Note: when using `--since` or `--last`, only changes up to the currently installed version are shown.
136
136
 
modal/cli/environment.py CHANGED
@@ -242,21 +242,21 @@ async def environment_billing(
242
242
 
243
243
  Examples:
244
244
 
245
- ```bash
246
- modal environment billing report --start 2025-12-01 --end 2026-01-01
245
+ ```bash
246
+ modal environment billing report --start 2025-12-01 --end 2026-01-01
247
247
 
248
- modal environment billing report --for "last month" --tag-names team,project
248
+ modal environment billing report --for "last month" --tag-names team,project
249
249
 
250
- modal environment billing report test_env --for today --resolution h
250
+ modal environment billing report test_env --for today --resolution h
251
251
 
252
- modal environment billing report test_env --for "this month" --show-resources
252
+ modal environment billing report test_env --for "this month" --show-resources
253
253
 
254
- modal environment billing report prod_env --for yesterday -r h --tz local
254
+ modal environment billing report prod_env --for yesterday -r h --tz local
255
255
 
256
- modal environment billing report main_env --for "last month" --csv > report.csv
256
+ modal environment billing report main_env --for "last month" --csv > report.csv
257
257
 
258
- modal environment billing report main_env --start 2025-12-01 --json > report.json
259
- ```
258
+ modal environment billing report main_env --start 2025-12-01 --json > report.json
259
+ ```
260
260
 
261
261
  """
262
262
  if json and csv:
modal/client.pyi CHANGED
@@ -35,7 +35,7 @@ class _Client:
35
35
  server_url: str,
36
36
  client_type: int,
37
37
  credentials: typing.Optional[tuple[str, str]],
38
- version: str = "1.5.2.dev22",
38
+ version: str = "1.5.3.dev0",
39
39
  ):
40
40
  """mdmd:hidden
41
41
  The Modal client object is not intended to be instantiated directly by users.
@@ -205,7 +205,7 @@ class Client:
205
205
  server_url: str,
206
206
  client_type: int,
207
207
  credentials: typing.Optional[tuple[str, str]],
208
- version: str = "1.5.2.dev22",
208
+ version: str = "1.5.3.dev0",
209
209
  ):
210
210
  """mdmd:hidden
211
211
  The Modal client object is not intended to be instantiated directly by users.
@@ -1,7 +1,7 @@
1
1
  # Copyright Modal Labs 2024
2
2
  import asyncio
3
3
  import platform
4
- from typing import BinaryIO, Generic, TypeVar
4
+ from typing import Generic, TypeVar
5
5
 
6
6
  from modal_proto import api_pb2
7
7
 
@@ -117,18 +117,6 @@ class _ContainerProcess(Generic[T]):
117
117
  )
118
118
  return self._returncode
119
119
 
120
- async def _stdin_write_stream(self, source: BinaryIO) -> int:
121
- """mdmd:hidden
122
- Stream `source` into the process's stdin and close it (EOF) on success.
123
-
124
- Returns the total bytes streamed.
125
- """
126
- return await self._command_router_client.exec_stdin_write_stream(
127
- task_id=self._task_id,
128
- exec_id=self._process_id,
129
- source=source,
130
- )
131
-
132
120
  async def poll(self) -> int | None:
133
121
  """Check if the container process has finished running.
134
122
 
@@ -53,14 +53,6 @@ class _ContainerProcess(typing.Generic[T]):
53
53
 
54
54
  @property
55
55
  def returncode(self) -> int: ...
56
- async def _stdin_write_stream(self, source: typing.BinaryIO) -> int:
57
- """mdmd:hidden
58
- Stream `source` into the process's stdin and close it (EOF) on success.
59
-
60
- Returns the total bytes streamed.
61
- """
62
- ...
63
-
64
56
  async def poll(self) -> typing.Optional[int]:
65
57
  """Check if the container process has finished running.
66
58
 
@@ -113,25 +105,6 @@ class ContainerProcess(typing.Generic[T]):
113
105
  @property
114
106
  def returncode(self) -> int: ...
115
107
 
116
- class ___stdin_write_stream_spec(typing_extensions.Protocol):
117
- def __call__(self, /, source: typing.BinaryIO) -> int:
118
- """mdmd:hidden
119
- Stream `source` into the process's stdin and close it (EOF) on success.
120
-
121
- Returns the total bytes streamed.
122
- """
123
- ...
124
-
125
- async def aio(self, /, source: typing.BinaryIO) -> int:
126
- """mdmd:hidden
127
- Stream `source` into the process's stdin and close it (EOF) on success.
128
-
129
- Returns the total bytes streamed.
130
- """
131
- ...
132
-
133
- _stdin_write_stream: ___stdin_write_stream_spec
134
-
135
108
  class __poll_spec(typing_extensions.Protocol):
136
109
  def __call__(self, /) -> typing.Optional[int]:
137
110
  """Check if the container process has finished running.
modal/functions.pyi CHANGED
@@ -491,7 +491,7 @@ class Function(
491
491
 
492
492
  _call_generator: ___call_generator_spec
493
493
 
494
- class __remote_spec(typing_extensions.Protocol[ReturnType_INNER, P_INNER]):
494
+ class __remote_spec(typing_extensions.Protocol[P_INNER, ReturnType_INNER]):
495
495
  def __call__(self, /, *args: P_INNER.args, **kwargs: P_INNER.kwargs) -> ReturnType_INNER:
496
496
  """Calls the function remotely, executing it with the given arguments and returning the execution's result.
497
497
 
@@ -516,7 +516,7 @@ class Function(
516
516
  """
517
517
  ...
518
518
 
519
- remote: __remote_spec[modal._functions.ReturnType, modal._functions.P]
519
+ remote: __remote_spec[modal._functions.P, modal._functions.ReturnType]
520
520
 
521
521
  class __remote_gen_spec(typing_extensions.Protocol):
522
522
  def __call__(self, /, *args, **kwargs) -> typing.Generator[typing.Any, None, None]:
@@ -566,7 +566,7 @@ class Function(
566
566
  """
567
567
  ...
568
568
 
569
- class ___experimental_spawn_spec(typing_extensions.Protocol[ReturnType_INNER, P_INNER]):
569
+ class ___experimental_spawn_spec(typing_extensions.Protocol[P_INNER, ReturnType_INNER]):
570
570
  def __call__(self, /, *args: P_INNER.args, **kwargs: P_INNER.kwargs) -> FunctionCall[ReturnType_INNER]:
571
571
  """[Experimental] Calls the function with the given arguments, without waiting for the results.
572
572
 
@@ -599,7 +599,7 @@ class Function(
599
599
  """
600
600
  ...
601
601
 
602
- _experimental_spawn: ___experimental_spawn_spec[modal._functions.ReturnType, modal._functions.P]
602
+ _experimental_spawn: ___experimental_spawn_spec[modal._functions.P, modal._functions.ReturnType]
603
603
 
604
604
  class ___spawn_map_inner_spec(typing_extensions.Protocol[P_INNER]):
605
605
  def __call__(self, /, *args: P_INNER.args, **kwargs: P_INNER.kwargs) -> None: ...
@@ -607,7 +607,7 @@ class Function(
607
607
 
608
608
  _spawn_map_inner: ___spawn_map_inner_spec[modal._functions.P]
609
609
 
610
- class __spawn_spec(typing_extensions.Protocol[ReturnType_INNER, P_INNER]):
610
+ class __spawn_spec(typing_extensions.Protocol[P_INNER, ReturnType_INNER]):
611
611
  def __call__(self, /, *args: P_INNER.args, **kwargs: P_INNER.kwargs) -> FunctionCall[ReturnType_INNER]:
612
612
  """Calls the function with the given arguments, without waiting for the results.
613
613
 
@@ -640,7 +640,7 @@ class Function(
640
640
  """
641
641
  ...
642
642
 
643
- spawn: __spawn_spec[modal._functions.ReturnType, modal._functions.P]
643
+ spawn: __spawn_spec[modal._functions.P, modal._functions.ReturnType]
644
644
 
645
645
  def get_raw_f(self) -> collections.abc.Callable[..., typing.Any]:
646
646
  """Return the inner Python object wrapped by this Modal Function.
modal/parallel_map.py CHANGED
@@ -654,16 +654,14 @@ async def _map_invocation_inputplane(
654
654
  stale_retry_duplicates = 0
655
655
  already_complete_duplicates = 0
656
656
  retried_outputs = 0
657
- input_queue_size = 0
658
657
  last_entry_id = ""
659
658
 
660
659
  # The input-plane server returns this after the first request.
661
660
  map_token = None
662
661
  map_token_received = asyncio.Event()
663
662
 
664
- # Single priority queue that holds *both* fresh inputs (timestamp == now)
665
- # and future retries (timestamp > now).
666
- queue: TimestampPriorityQueue[api_pb2.MapStartOrContinueItem] = TimestampPriorityQueue()
663
+ input_queue: asyncio.Queue[api_pb2.MapStartOrContinueItem | None] = asyncio.Queue()
664
+ retry_queue: TimestampPriorityQueue[api_pb2.MapStartOrContinueItem] = TimestampPriorityQueue()
667
665
 
668
666
  # Maximum number of inputs that may be in-flight (the server sends this in
669
667
  # the first response – fall back to the default if we never receive it for
@@ -682,7 +680,7 @@ async def _map_invocation_inputplane(
682
680
  map_items_manager = _MapItemsManager(
683
681
  retry_policy=retry_policy,
684
682
  function_call_invocation_type=api_pb2.FUNCTION_CALL_INVOCATION_TYPE_SYNC,
685
- retry_queue=queue,
683
+ retry_queue=retry_queue,
686
684
  sync_client_retries_enabled=True,
687
685
  max_inputs_outstanding=MAX_INPUTS_OUTSTANDING_DEFAULT,
688
686
  is_input_plane_instance=True,
@@ -729,61 +727,67 @@ async def _map_invocation_inputplane(
729
727
  async_map_ordered(input_iter(), create_input, concurrency=BLOB_MAX_PARALLELISM)
730
728
  ) as streamer:
731
729
  async for q_item in streamer:
732
- await queue.put(time.time(), q_item)
730
+ await input_queue.put(q_item)
733
731
 
734
732
  # All inputs have been read.
733
+ await input_queue.put(None)
735
734
  update_counters(set_have_all_inputs=True)
736
735
  yield
737
736
 
738
- async def pump_inputs():
737
+ async def send_map_start_or_continue(request_items: list[api_pb2.MapStartOrContinueItem]):
739
738
  nonlocal map_token, max_inputs_outstanding
740
- async for batch in queue_batch_iterator(queue, max_batch_size=MAP_INVOCATION_CHUNK_SIZE):
741
- # Convert the queued items into the proto format expected by the RPC.
742
- request_items: list[api_pb2.MapStartOrContinueItem] = [
743
- api_pb2.MapStartOrContinueItem(input=qi.input, attempt_token=qi.attempt_token) for qi in batch
744
- ]
739
+ request = api_pb2.MapStartOrContinueRequest(
740
+ function_id=function.object_id,
741
+ map_token=map_token or "",
742
+ parent_input_id=current_input_id() or "",
743
+ items=request_items,
744
+ )
745
745
 
746
- await map_items_manager.add_items_inputplane(request_items)
746
+ metadata = await client.get_input_plane_metadata(function._input_plane_region)
747
747
 
748
- # Build request
749
- request = api_pb2.MapStartOrContinueRequest(
750
- function_id=function.object_id,
751
- map_token=map_token,
752
- parent_input_id=current_input_id() or "",
753
- items=request_items,
754
- )
748
+ response: api_pb2.MapStartOrContinueResponse = await input_plane_stub.MapStartOrContinue(
749
+ request,
750
+ retry=Retry(
751
+ additional_status_codes=[Status.RESOURCE_EXHAUSTED],
752
+ max_delay=PUMP_INPUTS_MAX_RETRY_DELAY,
753
+ max_retries=None,
754
+ ),
755
+ metadata=metadata,
756
+ )
755
757
 
756
- metadata = await client.get_input_plane_metadata(function._input_plane_region)
758
+ # match response items to the corresponding request item index
759
+ response_items_idx_tuple = [
760
+ (request_items[idx].input.idx, attempt_token) for idx, attempt_token in enumerate(response.attempt_tokens)
761
+ ]
757
762
 
758
- response: api_pb2.MapStartOrContinueResponse = await input_plane_stub.MapStartOrContinue(
759
- request,
760
- retry=Retry(
761
- additional_status_codes=[Status.RESOURCE_EXHAUSTED],
762
- max_delay=PUMP_INPUTS_MAX_RETRY_DELAY,
763
- max_retries=None,
764
- ),
765
- metadata=metadata,
766
- )
763
+ map_items_manager.handle_put_continue_response(response_items_idx_tuple)
767
764
 
768
- # match response items to the corresponding request item index
769
- response_items_idx_tuple = [
770
- (request_items[idx].input.idx, attempt_token)
771
- for idx, attempt_token in enumerate(response.attempt_tokens)
772
- ]
765
+ # Set the function call id and actual retry policy with the data from the first response.
766
+ if map_token is None:
767
+ map_token = response.map_token
768
+ map_token_received.set()
769
+ max_inputs_outstanding = response.max_inputs_outstanding or MAX_INPUTS_OUTSTANDING_DEFAULT
770
+ map_items_manager.set_retry_policy(response.retry_policy)
771
+ # Update the retry policy for the first batch of inputs.
772
+ # Subsequent batches will have the correct user-specified retry policy
773
+ # set by the updated _MapItemsManager.
774
+ map_items_manager.update_items_retry_policy(response.retry_policy)
773
775
 
774
- map_items_manager.handle_put_continue_response(response_items_idx_tuple)
776
+ async def pump_inputs():
777
+ async for batch in queue_batch_iterator(input_queue, max_batch_size=MAP_INVOCATION_CHUNK_SIZE):
778
+ request_items: list[api_pb2.MapStartOrContinueItem] = list(batch)
779
+ await map_items_manager.add_items_inputplane(request_items)
780
+ await send_map_start_or_continue(request_items)
781
+ yield
775
782
 
776
- # Set the function call id and actual retry policy with the data from the first response.
777
- # This conditional is skipped for subsequent iterations of this for-loop.
778
- if map_token is None:
779
- map_token = response.map_token
780
- map_token_received.set()
781
- max_inputs_outstanding = response.max_inputs_outstanding or MAX_INPUTS_OUTSTANDING_DEFAULT
782
- map_items_manager.set_retry_policy(response.retry_policy)
783
- # Update the retry policy for the first batch of inputs.
784
- # Subsequent batches will have the correct user-specified retry policy
785
- # set by the updated _MapItemsManager.
786
- map_items_manager.update_items_retry_policy(response.retry_policy)
783
+ async def retry_inputs():
784
+ while True:
785
+ retry_item = await retry_queue.get()
786
+ if retry_item is None:
787
+ break
788
+ request_items = [retry_item]
789
+ await map_items_manager.add_items_inputplane(request_items)
790
+ await send_map_start_or_continue(request_items)
787
791
  yield
788
792
 
789
793
  async def check_lost_inputs():
@@ -897,8 +901,8 @@ async def _map_invocation_inputplane(
897
901
  async for item in stream:
898
902
  yield item
899
903
  finally:
900
- await queue.close()
901
- pass
904
+ await input_queue.put(None)
905
+ await retry_queue.close()
902
906
 
903
907
  async def fetch_output(item: api_pb2.FunctionGetOutputsItem) -> tuple[int, Any]:
904
908
  try:
@@ -945,7 +949,8 @@ async def _map_invocation_inputplane(
945
949
  f"no_context_duplicates={no_context_duplicates} stale_retry_duplicates={stale_retry_duplicates} "
946
950
  f"already_complete_duplicates={already_complete_duplicates} retried_outputs={retried_outputs} "
947
951
  f"map_token={map_token} max_inputs_outstanding={max_inputs_outstanding} "
948
- f"map_items_manager_size={len(map_items_manager)} input_queue_size={input_queue_size}"
952
+ f"map_items_manager_size={len(map_items_manager)} input_queue_size={input_queue.qsize()} "
953
+ f"retry_queue_size={retry_queue.qsize()}"
949
954
  )
950
955
 
951
956
  while True:
@@ -960,7 +965,7 @@ async def _map_invocation_inputplane(
960
965
  log_task = asyncio.create_task(log_debug_stats())
961
966
 
962
967
  async with aclosing(
963
- async_merge(drain_input_generator(), pump_inputs(), poll_outputs(), check_lost_inputs())
968
+ async_merge(drain_input_generator(), pump_inputs(), retry_inputs(), poll_outputs(), check_lost_inputs())
964
969
  ) as merged:
965
970
  async for maybe_output in merged:
966
971
  if maybe_output is not None: # ignore None sentinels