modal 1.1.1.dev15__py3-none-any.whl → 1.1.1.dev17__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.
Potentially problematic release.
This version of modal might be problematic. Click here for more details.
- modal/_functions.py +34 -16
- modal/_utils/auth_token_manager.py +1 -1
- modal/_utils/function_utils.py +1 -0
- modal/client.py +8 -1
- modal/client.pyi +9 -2
- modal/dict.py +1 -1
- modal/functions.pyi +6 -6
- modal/parallel_map.py +289 -0
- modal/parallel_map.pyi +21 -0
- modal/queue.py +1 -1
- modal/secret.py +3 -3
- {modal-1.1.1.dev15.dist-info → modal-1.1.1.dev17.dist-info}/METADATA +1 -1
- {modal-1.1.1.dev15.dist-info → modal-1.1.1.dev17.dist-info}/RECORD +21 -21
- modal_proto/api.proto +30 -1
- modal_proto/api_pb2.py +745 -705
- modal_proto/api_pb2.pyi +108 -4
- modal_version/__init__.py +1 -1
- {modal-1.1.1.dev15.dist-info → modal-1.1.1.dev17.dist-info}/WHEEL +0 -0
- {modal-1.1.1.dev15.dist-info → modal-1.1.1.dev17.dist-info}/entry_points.txt +0 -0
- {modal-1.1.1.dev15.dist-info → modal-1.1.1.dev17.dist-info}/licenses/LICENSE +0 -0
- {modal-1.1.1.dev15.dist-info → modal-1.1.1.dev17.dist-info}/top_level.txt +0 -0
modal/_functions.py
CHANGED
|
@@ -75,6 +75,7 @@ from .parallel_map import (
|
|
|
75
75
|
_for_each_sync,
|
|
76
76
|
_map_async,
|
|
77
77
|
_map_invocation,
|
|
78
|
+
_map_invocation_inputplane,
|
|
78
79
|
_map_sync,
|
|
79
80
|
_spawn_map_async,
|
|
80
81
|
_spawn_map_sync,
|
|
@@ -399,7 +400,8 @@ class _InputPlaneInvocation:
|
|
|
399
400
|
parent_input_id=current_input_id() or "",
|
|
400
401
|
input=input_item,
|
|
401
402
|
)
|
|
402
|
-
|
|
403
|
+
|
|
404
|
+
metadata = await client.get_input_plane_metadata(input_plane_region)
|
|
403
405
|
response = await retry_transient_errors(stub.AttemptStart, request, metadata=metadata)
|
|
404
406
|
attempt_token = response.attempt_token
|
|
405
407
|
|
|
@@ -415,7 +417,7 @@ class _InputPlaneInvocation:
|
|
|
415
417
|
timeout_secs=OUTPUTS_TIMEOUT,
|
|
416
418
|
requested_at=time.time(),
|
|
417
419
|
)
|
|
418
|
-
metadata = await self.
|
|
420
|
+
metadata = await self.client.get_input_plane_metadata(self.input_plane_region)
|
|
419
421
|
await_response: api_pb2.AttemptAwaitResponse = await retry_transient_errors(
|
|
420
422
|
self.stub.AttemptAwait,
|
|
421
423
|
await_request,
|
|
@@ -1514,20 +1516,36 @@ Use the `Function.get_web_url()` method instead.
|
|
|
1514
1516
|
else:
|
|
1515
1517
|
count_update_callback = None
|
|
1516
1518
|
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1519
|
+
# TODO(ben-okeefe): Feature gating for input plane map until feature is enabled.
|
|
1520
|
+
if self._input_plane_url and False:
|
|
1521
|
+
async with aclosing(
|
|
1522
|
+
_map_invocation_inputplane(
|
|
1523
|
+
self,
|
|
1524
|
+
input_queue,
|
|
1525
|
+
self.client,
|
|
1526
|
+
order_outputs,
|
|
1527
|
+
return_exceptions,
|
|
1528
|
+
wrap_returned_exceptions,
|
|
1529
|
+
count_update_callback,
|
|
1530
|
+
)
|
|
1531
|
+
) as stream:
|
|
1532
|
+
async for item in stream:
|
|
1533
|
+
yield item
|
|
1534
|
+
else:
|
|
1535
|
+
async with aclosing(
|
|
1536
|
+
_map_invocation(
|
|
1537
|
+
self,
|
|
1538
|
+
input_queue,
|
|
1539
|
+
self.client,
|
|
1540
|
+
order_outputs,
|
|
1541
|
+
return_exceptions,
|
|
1542
|
+
wrap_returned_exceptions,
|
|
1543
|
+
count_update_callback,
|
|
1544
|
+
api_pb2.FUNCTION_CALL_INVOCATION_TYPE_SYNC,
|
|
1545
|
+
)
|
|
1546
|
+
) as stream:
|
|
1547
|
+
async for item in stream:
|
|
1548
|
+
yield item
|
|
1531
1549
|
|
|
1532
1550
|
async def _call_function(self, args, kwargs) -> ReturnType:
|
|
1533
1551
|
invocation: Union[_Invocation, _InputPlaneInvocation]
|
|
@@ -27,7 +27,7 @@ class _AuthTokenManager:
|
|
|
27
27
|
self._expiry = 0.0
|
|
28
28
|
self._lock: typing.Union[asyncio.Lock, None] = None
|
|
29
29
|
|
|
30
|
-
async def get_token(self):
|
|
30
|
+
async def get_token(self) -> str:
|
|
31
31
|
"""
|
|
32
32
|
When called, the AuthTokenManager can be in one of three states:
|
|
33
33
|
1. Has a valid cached token. It is returned to the caller.
|
modal/_utils/function_utils.py
CHANGED
modal/client.py
CHANGED
|
@@ -268,6 +268,14 @@ class _Client:
|
|
|
268
268
|
# Just used from tests.
|
|
269
269
|
cls._client_from_env = client
|
|
270
270
|
|
|
271
|
+
async def get_input_plane_metadata(self, input_plane_region: str) -> list[tuple[str, str]]:
|
|
272
|
+
assert self._auth_token_manager, "Client must have an instance of auth token manager."
|
|
273
|
+
token = await self._auth_token_manager.get_token()
|
|
274
|
+
return [
|
|
275
|
+
("x-modal-input-plane-region", input_plane_region),
|
|
276
|
+
("x-modal-auth-token", token),
|
|
277
|
+
]
|
|
278
|
+
|
|
271
279
|
async def _call_safely(self, coro, readable_method: str):
|
|
272
280
|
"""Runs coroutine wrapped in a task that's part of the client's task context
|
|
273
281
|
|
|
@@ -456,4 +464,3 @@ class UnaryStreamWrapper(Generic[RequestType, ResponseType]):
|
|
|
456
464
|
self.wrapped_method.channel = await self.client._get_channel(self.server_url)
|
|
457
465
|
async for response in self.client._call_stream(self.wrapped_method, request, metadata=metadata):
|
|
458
466
|
yield response
|
|
459
|
-
|
modal/client.pyi
CHANGED
|
@@ -33,7 +33,7 @@ class _Client:
|
|
|
33
33
|
server_url: str,
|
|
34
34
|
client_type: int,
|
|
35
35
|
credentials: typing.Optional[tuple[str, str]],
|
|
36
|
-
version: str = "1.1.1.
|
|
36
|
+
version: str = "1.1.1.dev17",
|
|
37
37
|
):
|
|
38
38
|
"""mdmd:hidden
|
|
39
39
|
The Modal client object is not intended to be instantiated directly by users.
|
|
@@ -112,6 +112,7 @@ class _Client:
|
|
|
112
112
|
"""mdmd:hidden"""
|
|
113
113
|
...
|
|
114
114
|
|
|
115
|
+
async def get_input_plane_metadata(self, input_plane_region: str) -> list[tuple[str, str]]: ...
|
|
115
116
|
async def _call_safely(self, coro, readable_method: str):
|
|
116
117
|
"""Runs coroutine wrapped in a task that's part of the client's task context
|
|
117
118
|
|
|
@@ -163,7 +164,7 @@ class Client:
|
|
|
163
164
|
server_url: str,
|
|
164
165
|
client_type: int,
|
|
165
166
|
credentials: typing.Optional[tuple[str, str]],
|
|
166
|
-
version: str = "1.1.1.
|
|
167
|
+
version: str = "1.1.1.dev17",
|
|
167
168
|
):
|
|
168
169
|
"""mdmd:hidden
|
|
169
170
|
The Modal client object is not intended to be instantiated directly by users.
|
|
@@ -275,6 +276,12 @@ class Client:
|
|
|
275
276
|
"""mdmd:hidden"""
|
|
276
277
|
...
|
|
277
278
|
|
|
279
|
+
class __get_input_plane_metadata_spec(typing_extensions.Protocol[SUPERSELF]):
|
|
280
|
+
def __call__(self, /, input_plane_region: str) -> list[tuple[str, str]]: ...
|
|
281
|
+
async def aio(self, /, input_plane_region: str) -> list[tuple[str, str]]: ...
|
|
282
|
+
|
|
283
|
+
get_input_plane_metadata: __get_input_plane_metadata_spec[typing_extensions.Self]
|
|
284
|
+
|
|
278
285
|
class ___call_safely_spec(typing_extensions.Protocol[SUPERSELF]):
|
|
279
286
|
def __call__(self, /, coro, readable_method: str):
|
|
280
287
|
"""Runs coroutine wrapped in a task that's part of the client's task context
|
modal/dict.py
CHANGED
|
@@ -153,7 +153,7 @@ class _Dict(_Object, type_prefix="di"):
|
|
|
153
153
|
)
|
|
154
154
|
response = await resolver.client.stub.DictGetOrCreate(req)
|
|
155
155
|
logger.debug(f"Created dict with id {response.dict_id}")
|
|
156
|
-
self._hydrate(response.dict_id, resolver.client,
|
|
156
|
+
self._hydrate(response.dict_id, resolver.client, response.metadata)
|
|
157
157
|
|
|
158
158
|
return _Dict._from_loader(_load, "Dict()", is_another_app=True, hydrate_lazily=True)
|
|
159
159
|
|
modal/functions.pyi
CHANGED
|
@@ -428,7 +428,7 @@ class Function(
|
|
|
428
428
|
|
|
429
429
|
_call_generator: ___call_generator_spec[typing_extensions.Self]
|
|
430
430
|
|
|
431
|
-
class __remote_spec(typing_extensions.Protocol[
|
|
431
|
+
class __remote_spec(typing_extensions.Protocol[P_INNER, ReturnType_INNER, SUPERSELF]):
|
|
432
432
|
def __call__(self, /, *args: P_INNER.args, **kwargs: P_INNER.kwargs) -> ReturnType_INNER:
|
|
433
433
|
"""Calls the function remotely, executing it with the given arguments and returning the execution's result."""
|
|
434
434
|
...
|
|
@@ -437,7 +437,7 @@ class Function(
|
|
|
437
437
|
"""Calls the function remotely, executing it with the given arguments and returning the execution's result."""
|
|
438
438
|
...
|
|
439
439
|
|
|
440
|
-
remote: __remote_spec[modal._functions.
|
|
440
|
+
remote: __remote_spec[modal._functions.P, modal._functions.ReturnType, typing_extensions.Self]
|
|
441
441
|
|
|
442
442
|
class __remote_gen_spec(typing_extensions.Protocol[SUPERSELF]):
|
|
443
443
|
def __call__(self, /, *args, **kwargs) -> typing.Generator[typing.Any, None, None]:
|
|
@@ -464,7 +464,7 @@ class Function(
|
|
|
464
464
|
"""
|
|
465
465
|
...
|
|
466
466
|
|
|
467
|
-
class ___experimental_spawn_spec(typing_extensions.Protocol[
|
|
467
|
+
class ___experimental_spawn_spec(typing_extensions.Protocol[P_INNER, ReturnType_INNER, SUPERSELF]):
|
|
468
468
|
def __call__(self, /, *args: P_INNER.args, **kwargs: P_INNER.kwargs) -> FunctionCall[ReturnType_INNER]:
|
|
469
469
|
"""[Experimental] Calls the function with the given arguments, without waiting for the results.
|
|
470
470
|
|
|
@@ -488,7 +488,7 @@ class Function(
|
|
|
488
488
|
...
|
|
489
489
|
|
|
490
490
|
_experimental_spawn: ___experimental_spawn_spec[
|
|
491
|
-
modal._functions.
|
|
491
|
+
modal._functions.P, modal._functions.ReturnType, typing_extensions.Self
|
|
492
492
|
]
|
|
493
493
|
|
|
494
494
|
class ___spawn_map_inner_spec(typing_extensions.Protocol[P_INNER, SUPERSELF]):
|
|
@@ -497,7 +497,7 @@ class Function(
|
|
|
497
497
|
|
|
498
498
|
_spawn_map_inner: ___spawn_map_inner_spec[modal._functions.P, typing_extensions.Self]
|
|
499
499
|
|
|
500
|
-
class __spawn_spec(typing_extensions.Protocol[
|
|
500
|
+
class __spawn_spec(typing_extensions.Protocol[P_INNER, ReturnType_INNER, SUPERSELF]):
|
|
501
501
|
def __call__(self, /, *args: P_INNER.args, **kwargs: P_INNER.kwargs) -> FunctionCall[ReturnType_INNER]:
|
|
502
502
|
"""Calls the function with the given arguments, without waiting for the results.
|
|
503
503
|
|
|
@@ -518,7 +518,7 @@ class Function(
|
|
|
518
518
|
"""
|
|
519
519
|
...
|
|
520
520
|
|
|
521
|
-
spawn: __spawn_spec[modal._functions.
|
|
521
|
+
spawn: __spawn_spec[modal._functions.P, modal._functions.ReturnType, typing_extensions.Self]
|
|
522
522
|
|
|
523
523
|
def get_raw_f(self) -> collections.abc.Callable[..., typing.Any]:
|
|
524
524
|
"""Return the inner Python object wrapped by this Modal Function."""
|
modal/parallel_map.py
CHANGED
|
@@ -424,6 +424,295 @@ async def _map_invocation(
|
|
|
424
424
|
await log_debug_stats_task
|
|
425
425
|
|
|
426
426
|
|
|
427
|
+
async def _map_invocation_inputplane(
|
|
428
|
+
function: "modal.functions._Function",
|
|
429
|
+
raw_input_queue: _SynchronizedQueue,
|
|
430
|
+
client: "modal.client._Client",
|
|
431
|
+
order_outputs: bool,
|
|
432
|
+
return_exceptions: bool,
|
|
433
|
+
wrap_returned_exceptions: bool,
|
|
434
|
+
count_update_callback: Optional[Callable[[int, int], None]],
|
|
435
|
+
) -> typing.AsyncGenerator[Any, None]:
|
|
436
|
+
"""Input-plane implementation of a function map invocation.
|
|
437
|
+
|
|
438
|
+
This is analogous to `_map_invocation`, but instead of the control-plane
|
|
439
|
+
`FunctionMap` / `FunctionPutInputs` / `FunctionGetOutputs` RPCs it speaks
|
|
440
|
+
the input-plane protocol consisting of `MapStartOrContinue` and `MapAwait`.
|
|
441
|
+
|
|
442
|
+
The implementation purposefully ignores retry handling for now - a stub is
|
|
443
|
+
left in place so that a future change can add support for the retry path
|
|
444
|
+
without re-structuring the surrounding code.
|
|
445
|
+
"""
|
|
446
|
+
|
|
447
|
+
assert function._input_plane_url, "_map_invocation_inputplane should only be used for input-plane backed functions"
|
|
448
|
+
|
|
449
|
+
input_plane_stub = await client.get_stub(function._input_plane_url)
|
|
450
|
+
|
|
451
|
+
assert client.stub, "Client must be hydrated with a stub for _map_invocation_inputplane"
|
|
452
|
+
|
|
453
|
+
# ------------------------------------------------------------
|
|
454
|
+
# Invocation-wide state
|
|
455
|
+
# ------------------------------------------------------------
|
|
456
|
+
|
|
457
|
+
have_all_inputs = False
|
|
458
|
+
map_done_event = asyncio.Event()
|
|
459
|
+
|
|
460
|
+
inputs_created = 0
|
|
461
|
+
outputs_completed = 0
|
|
462
|
+
|
|
463
|
+
# The input-plane server returns this after the first request.
|
|
464
|
+
function_call_id: str | None = None
|
|
465
|
+
function_call_id_received = asyncio.Event()
|
|
466
|
+
|
|
467
|
+
# Map of idx -> attempt_token returned by the server. This will be needed
|
|
468
|
+
# for a future client-side retry implementation.
|
|
469
|
+
attempt_tokens: dict[int, str] = {}
|
|
470
|
+
|
|
471
|
+
# Single priority queue that holds *both* fresh inputs (timestamp == now)
|
|
472
|
+
# and future retries (timestamp > now).
|
|
473
|
+
queue: TimestampPriorityQueue[api_pb2.MapStartOrContinueItem] = TimestampPriorityQueue()
|
|
474
|
+
|
|
475
|
+
# Maximum number of inputs that may be in-flight (the server sends this in
|
|
476
|
+
# the first response – fall back to the default if we never receive it for
|
|
477
|
+
# any reason).
|
|
478
|
+
max_inputs_outstanding = MAX_INPUTS_OUTSTANDING_DEFAULT
|
|
479
|
+
|
|
480
|
+
# ------------------------------------------------------------
|
|
481
|
+
# Helper functions
|
|
482
|
+
# ------------------------------------------------------------
|
|
483
|
+
|
|
484
|
+
def update_counters(created_delta: int = 0, completed_delta: int = 0, set_have_all_inputs: bool | None = None):
|
|
485
|
+
nonlocal inputs_created, outputs_completed, have_all_inputs
|
|
486
|
+
|
|
487
|
+
if created_delta:
|
|
488
|
+
inputs_created += created_delta
|
|
489
|
+
if completed_delta:
|
|
490
|
+
outputs_completed += completed_delta
|
|
491
|
+
if set_have_all_inputs is not None:
|
|
492
|
+
have_all_inputs = set_have_all_inputs
|
|
493
|
+
|
|
494
|
+
if count_update_callback is not None:
|
|
495
|
+
count_update_callback(outputs_completed, inputs_created)
|
|
496
|
+
|
|
497
|
+
if have_all_inputs and outputs_completed >= inputs_created:
|
|
498
|
+
map_done_event.set()
|
|
499
|
+
|
|
500
|
+
async def create_input(argskwargs):
|
|
501
|
+
idx = inputs_created + 1 # 1-indexed map call idx
|
|
502
|
+
update_counters(created_delta=1)
|
|
503
|
+
(args, kwargs) = argskwargs
|
|
504
|
+
put_item: api_pb2.FunctionPutInputsItem = await _create_input(
|
|
505
|
+
args,
|
|
506
|
+
kwargs,
|
|
507
|
+
client.stub,
|
|
508
|
+
max_object_size_bytes=function._max_object_size_bytes,
|
|
509
|
+
idx=idx,
|
|
510
|
+
method_name=function._use_method_name,
|
|
511
|
+
)
|
|
512
|
+
return api_pb2.MapStartOrContinueItem(input=put_item)
|
|
513
|
+
|
|
514
|
+
# ------------------------------------------------------------
|
|
515
|
+
# Coroutine: drain user input iterator, upload blobs, enqueue for sending
|
|
516
|
+
# ------------------------------------------------------------
|
|
517
|
+
|
|
518
|
+
async def input_iter():
|
|
519
|
+
while True:
|
|
520
|
+
raw_input = await raw_input_queue.get()
|
|
521
|
+
if raw_input is None: # end of input sentinel
|
|
522
|
+
break
|
|
523
|
+
yield raw_input # args, kwargs
|
|
524
|
+
|
|
525
|
+
async def drain_input_generator():
|
|
526
|
+
async with aclosing(
|
|
527
|
+
async_map_ordered(input_iter(), create_input, concurrency=BLOB_MAX_PARALLELISM)
|
|
528
|
+
) as streamer:
|
|
529
|
+
async for q_item in streamer:
|
|
530
|
+
await queue.put(time.time(), q_item)
|
|
531
|
+
|
|
532
|
+
# All inputs have been read.
|
|
533
|
+
await queue.close()
|
|
534
|
+
update_counters(set_have_all_inputs=True)
|
|
535
|
+
yield
|
|
536
|
+
|
|
537
|
+
# ------------------------------------------------------------
|
|
538
|
+
# Coroutine: send queued items to the input-plane server
|
|
539
|
+
# ------------------------------------------------------------
|
|
540
|
+
|
|
541
|
+
async def pump_inputs():
|
|
542
|
+
nonlocal function_call_id, max_inputs_outstanding
|
|
543
|
+
|
|
544
|
+
async for batch in queue_batch_iterator(queue, max_batch_size=MAP_INVOCATION_CHUNK_SIZE):
|
|
545
|
+
# Convert the queued items into the proto format expected by the RPC.
|
|
546
|
+
request_items: list[api_pb2.MapStartOrContinueItem] = [
|
|
547
|
+
api_pb2.MapStartOrContinueItem(input=qi.input, attempt_token=qi.attempt_token) for qi in batch
|
|
548
|
+
]
|
|
549
|
+
# Build request
|
|
550
|
+
request = api_pb2.MapStartOrContinueRequest(
|
|
551
|
+
function_id=function.object_id,
|
|
552
|
+
function_call_id=function_call_id,
|
|
553
|
+
parent_input_id=current_input_id() or "",
|
|
554
|
+
items=request_items,
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
metadata = await client.get_input_plane_metadata(function._input_plane_region)
|
|
558
|
+
|
|
559
|
+
response: api_pb2.MapStartOrContinueResponse = await retry_transient_errors(
|
|
560
|
+
input_plane_stub.MapStartOrContinue, request, metadata=metadata
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
# TODO(ben-okeefe): Understand if an input could be lost at this step and not registered
|
|
564
|
+
|
|
565
|
+
if function_call_id is None:
|
|
566
|
+
function_call_id = response.function_call_id
|
|
567
|
+
function_call_id_received.set()
|
|
568
|
+
max_inputs_outstanding = response.max_inputs_outstanding or MAX_INPUTS_OUTSTANDING_DEFAULT
|
|
569
|
+
|
|
570
|
+
# Record attempt tokens for future retries; also release semaphore slots now that the
|
|
571
|
+
# inputs are officially registered on the server.
|
|
572
|
+
for idx, attempt_token in enumerate(response.attempt_tokens):
|
|
573
|
+
# Client expects the server to return the attempt tokens in the same order as the inputs we sent.
|
|
574
|
+
attempt_tokens[request_items[idx].input.idx] = attempt_token
|
|
575
|
+
|
|
576
|
+
yield
|
|
577
|
+
|
|
578
|
+
# ------------------------------------------------------------
|
|
579
|
+
# Coroutine: **stub** – retry handling will be added in the future
|
|
580
|
+
# ------------------------------------------------------------
|
|
581
|
+
|
|
582
|
+
async def retry_inputs():
|
|
583
|
+
"""Temporary stub for retrying inputs. Retry handling will be added in the future."""
|
|
584
|
+
|
|
585
|
+
try:
|
|
586
|
+
while not map_done_event.is_set():
|
|
587
|
+
await asyncio.sleep(1)
|
|
588
|
+
if False:
|
|
589
|
+
yield
|
|
590
|
+
except asyncio.CancelledError:
|
|
591
|
+
pass
|
|
592
|
+
|
|
593
|
+
# ------------------------------------------------------------
|
|
594
|
+
# Coroutine: stream outputs via MapAwait
|
|
595
|
+
# ------------------------------------------------------------
|
|
596
|
+
|
|
597
|
+
async def get_all_outputs():
|
|
598
|
+
"""Continuously fetch outputs until the map is complete."""
|
|
599
|
+
last_entry_id = ""
|
|
600
|
+
while not map_done_event.is_set():
|
|
601
|
+
if function_call_id is None:
|
|
602
|
+
await function_call_id_received.wait()
|
|
603
|
+
continue
|
|
604
|
+
|
|
605
|
+
request = api_pb2.MapAwaitRequest(
|
|
606
|
+
function_call_id=function_call_id,
|
|
607
|
+
last_entry_id=last_entry_id,
|
|
608
|
+
requested_at=time.time(),
|
|
609
|
+
timeout=OUTPUTS_TIMEOUT,
|
|
610
|
+
)
|
|
611
|
+
metadata = await client.get_input_plane_metadata(function._input_plane_region)
|
|
612
|
+
response: api_pb2.MapAwaitResponse = await retry_transient_errors(
|
|
613
|
+
input_plane_stub.MapAwait,
|
|
614
|
+
request,
|
|
615
|
+
max_retries=20,
|
|
616
|
+
attempt_timeout=OUTPUTS_TIMEOUT + ATTEMPT_TIMEOUT_GRACE_PERIOD,
|
|
617
|
+
metadata=metadata,
|
|
618
|
+
)
|
|
619
|
+
last_entry_id = response.last_entry_id
|
|
620
|
+
|
|
621
|
+
for output_item in response.outputs:
|
|
622
|
+
yield output_item
|
|
623
|
+
|
|
624
|
+
update_counters(completed_delta=1)
|
|
625
|
+
|
|
626
|
+
# The loop condition will exit when map_done_event is set from update_counters.
|
|
627
|
+
|
|
628
|
+
async def get_all_outputs_and_clean_up():
|
|
629
|
+
try:
|
|
630
|
+
async with aclosing(get_all_outputs()) as stream:
|
|
631
|
+
async for item in stream:
|
|
632
|
+
yield item
|
|
633
|
+
finally:
|
|
634
|
+
# We could signal server we are done with outputs so it can clean up.
|
|
635
|
+
pass
|
|
636
|
+
|
|
637
|
+
# ------------------------------------------------------------
|
|
638
|
+
# Coroutine: convert FunctionGetOutputsItem → actual result value
|
|
639
|
+
# ------------------------------------------------------------
|
|
640
|
+
|
|
641
|
+
async def fetch_output(item: api_pb2.FunctionGetOutputsItem) -> tuple[int, Any]:
|
|
642
|
+
try:
|
|
643
|
+
output_val = await _process_result(item.result, item.data_format, input_plane_stub, client)
|
|
644
|
+
except Exception as exc:
|
|
645
|
+
if return_exceptions:
|
|
646
|
+
output_val = exc
|
|
647
|
+
else:
|
|
648
|
+
raise exc
|
|
649
|
+
|
|
650
|
+
return (item.idx, output_val)
|
|
651
|
+
|
|
652
|
+
async def poll_outputs():
|
|
653
|
+
# map to store out-of-order outputs received
|
|
654
|
+
received_outputs = {}
|
|
655
|
+
output_idx = 1 # 1-indexed map call idx
|
|
656
|
+
|
|
657
|
+
async with aclosing(
|
|
658
|
+
async_map_ordered(get_all_outputs_and_clean_up(), fetch_output, concurrency=BLOB_MAX_PARALLELISM)
|
|
659
|
+
) as streamer:
|
|
660
|
+
async for idx, output in streamer:
|
|
661
|
+
if not order_outputs:
|
|
662
|
+
yield _OutputValue(output)
|
|
663
|
+
else:
|
|
664
|
+
# hold on to outputs for function maps, so we can reorder them correctly.
|
|
665
|
+
received_outputs[idx] = output
|
|
666
|
+
|
|
667
|
+
while True:
|
|
668
|
+
if output_idx not in received_outputs:
|
|
669
|
+
# we haven't received the output for the current index yet.
|
|
670
|
+
# stop returning outputs to the caller and instead wait for
|
|
671
|
+
# the next output to arrive from the server.
|
|
672
|
+
break
|
|
673
|
+
|
|
674
|
+
output = received_outputs.pop(output_idx)
|
|
675
|
+
yield _OutputValue(output)
|
|
676
|
+
output_idx += 1
|
|
677
|
+
|
|
678
|
+
assert len(received_outputs) == 0
|
|
679
|
+
|
|
680
|
+
# ------------------------------------------------------------
|
|
681
|
+
# Debug-logging helper
|
|
682
|
+
# ------------------------------------------------------------
|
|
683
|
+
async def log_debug_stats():
|
|
684
|
+
def log_stats():
|
|
685
|
+
logger.debug(
|
|
686
|
+
"Map-IP stats: have_all_inputs=%s inputs_created=%d outputs_completed=%d queue_size=%d",
|
|
687
|
+
have_all_inputs,
|
|
688
|
+
inputs_created,
|
|
689
|
+
outputs_completed,
|
|
690
|
+
queue.qsize(),
|
|
691
|
+
)
|
|
692
|
+
|
|
693
|
+
while True:
|
|
694
|
+
log_stats()
|
|
695
|
+
try:
|
|
696
|
+
await asyncio.sleep(10)
|
|
697
|
+
except asyncio.CancelledError:
|
|
698
|
+
# Log final stats before exiting
|
|
699
|
+
log_stats()
|
|
700
|
+
break
|
|
701
|
+
|
|
702
|
+
# ------------------------------------------------------------
|
|
703
|
+
# Run the four coroutines concurrently and yield results as they arrive
|
|
704
|
+
# ------------------------------------------------------------
|
|
705
|
+
|
|
706
|
+
log_task = asyncio.create_task(log_debug_stats())
|
|
707
|
+
|
|
708
|
+
async with aclosing(async_merge(drain_input_generator(), pump_inputs(), poll_outputs(), retry_inputs())) as merged:
|
|
709
|
+
async for maybe_output in merged:
|
|
710
|
+
if maybe_output is not None: # ignore None sentinels
|
|
711
|
+
yield maybe_output.value
|
|
712
|
+
|
|
713
|
+
log_task.cancel()
|
|
714
|
+
|
|
715
|
+
|
|
427
716
|
async def _map_helper(
|
|
428
717
|
self: "modal.functions.Function",
|
|
429
718
|
async_input_gen: typing.AsyncGenerator[Any, None],
|
modal/parallel_map.pyi
CHANGED
|
@@ -70,6 +70,27 @@ def _map_invocation(
|
|
|
70
70
|
count_update_callback: typing.Optional[collections.abc.Callable[[int, int], None]],
|
|
71
71
|
function_call_invocation_type: int,
|
|
72
72
|
): ...
|
|
73
|
+
def _map_invocation_inputplane(
|
|
74
|
+
function: modal._functions._Function,
|
|
75
|
+
raw_input_queue: _SynchronizedQueue,
|
|
76
|
+
client: modal.client._Client,
|
|
77
|
+
order_outputs: bool,
|
|
78
|
+
return_exceptions: bool,
|
|
79
|
+
wrap_returned_exceptions: bool,
|
|
80
|
+
count_update_callback: typing.Optional[collections.abc.Callable[[int, int], None]],
|
|
81
|
+
) -> typing.AsyncGenerator[typing.Any, None]:
|
|
82
|
+
"""Input-plane implementation of a function map invocation.
|
|
83
|
+
|
|
84
|
+
This is analogous to `_map_invocation`, but instead of the control-plane
|
|
85
|
+
`FunctionMap` / `FunctionPutInputs` / `FunctionGetOutputs` RPCs it speaks
|
|
86
|
+
the input-plane protocol consisting of `MapStartOrContinue` and `MapAwait`.
|
|
87
|
+
|
|
88
|
+
The implementation purposefully ignores retry handling for now - a stub is
|
|
89
|
+
left in place so that a future change can add support for the retry path
|
|
90
|
+
without re-structuring the surrounding code.
|
|
91
|
+
"""
|
|
92
|
+
...
|
|
93
|
+
|
|
73
94
|
def _map_helper(
|
|
74
95
|
self: modal.functions.Function,
|
|
75
96
|
async_input_gen: typing.AsyncGenerator[typing.Any, None],
|
modal/queue.py
CHANGED
|
@@ -173,7 +173,7 @@ class _Queue(_Object, type_prefix="qu"):
|
|
|
173
173
|
object_creation_type=(api_pb2.OBJECT_CREATION_TYPE_CREATE_IF_MISSING if create_if_missing else None),
|
|
174
174
|
)
|
|
175
175
|
response = await resolver.client.stub.QueueGetOrCreate(req)
|
|
176
|
-
self._hydrate(response.queue_id, resolver.client,
|
|
176
|
+
self._hydrate(response.queue_id, resolver.client, response.metadata)
|
|
177
177
|
|
|
178
178
|
return _Queue._from_loader(_load, "Queue()", is_another_app=True, hydrate_lazily=True)
|
|
179
179
|
|
modal/secret.py
CHANGED
|
@@ -73,7 +73,7 @@ class _Secret(_Object, type_prefix="st"):
|
|
|
73
73
|
if exc.status == Status.FAILED_PRECONDITION:
|
|
74
74
|
raise InvalidError(exc.message)
|
|
75
75
|
raise
|
|
76
|
-
self._hydrate(resp.secret_id, resolver.client,
|
|
76
|
+
self._hydrate(resp.secret_id, resolver.client, resp.metadata)
|
|
77
77
|
|
|
78
78
|
rep = f"Secret.from_dict([{', '.join(env_dict.keys())}])"
|
|
79
79
|
return _Secret._from_loader(_load, rep, hydrate_lazily=True)
|
|
@@ -157,7 +157,7 @@ class _Secret(_Object, type_prefix="st"):
|
|
|
157
157
|
)
|
|
158
158
|
resp = await resolver.client.stub.SecretGetOrCreate(req)
|
|
159
159
|
|
|
160
|
-
self._hydrate(resp.secret_id, resolver.client,
|
|
160
|
+
self._hydrate(resp.secret_id, resolver.client, resp.metadata)
|
|
161
161
|
|
|
162
162
|
return _Secret._from_loader(_load, "Secret.from_dotenv()", hydrate_lazily=True)
|
|
163
163
|
|
|
@@ -200,7 +200,7 @@ class _Secret(_Object, type_prefix="st"):
|
|
|
200
200
|
raise NotFoundError(exc.message)
|
|
201
201
|
else:
|
|
202
202
|
raise
|
|
203
|
-
self._hydrate(response.secret_id, resolver.client,
|
|
203
|
+
self._hydrate(response.secret_id, resolver.client, response.metadata)
|
|
204
204
|
|
|
205
205
|
return _Secret._from_loader(_load, "Secret()", hydrate_lazily=True)
|
|
206
206
|
|