modal 1.6.2.dev7__py3-none-any.whl → 1.6.2.dev8__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.
- modal/_utils/async_utils.py +3 -1
- modal/_utils/blob_utils.py +2 -0
- modal/cli/_logs.py +5 -5
- modal/cli/container.py +2 -2
- modal/cli/function.py +241 -3
- modal/cli/server.py +280 -3
- modal/cli/utils.py +29 -1
- modal/client.pyi +2 -2
- modal/cls.py +1 -1
- modal/cls.pyi +1 -1
- modal/functions.pyi +6 -6
- modal/mount.py +12 -1
- modal/queue.py +1 -1
- modal/queue.pyi +2 -2
- modal/volume.py +10 -0
- modal/volume.pyi +3 -0
- {modal-1.6.2.dev7.dist-info → modal-1.6.2.dev8.dist-info}/METADATA +1 -1
- {modal-1.6.2.dev7.dist-info → modal-1.6.2.dev8.dist-info}/RECORD +29 -29
- modal_proto/api_grpc.py +0 -16
- modal_proto/api_pb2.py +1273 -1321
- modal_proto/api_pb2.pyi +32 -131
- modal_proto/api_pb2_grpc.py +0 -33
- modal_proto/api_pb2_grpc.pyi +0 -10
- modal_proto/modal_api_grpc.py +0 -1
- modal_version/__init__.py +1 -1
- {modal-1.6.2.dev7.dist-info → modal-1.6.2.dev8.dist-info}/WHEEL +0 -0
- {modal-1.6.2.dev7.dist-info → modal-1.6.2.dev8.dist-info}/entry_points.txt +0 -0
- {modal-1.6.2.dev7.dist-info → modal-1.6.2.dev8.dist-info}/licenses/LICENSE +0 -0
- {modal-1.6.2.dev7.dist-info → modal-1.6.2.dev8.dist-info}/top_level.txt +0 -0
modal/_utils/async_utils.py
CHANGED
|
@@ -378,7 +378,7 @@ def retry(
|
|
|
378
378
|
|
|
379
379
|
Usage:
|
|
380
380
|
|
|
381
|
-
```
|
|
381
|
+
```python notest
|
|
382
382
|
@retry
|
|
383
383
|
async def may_fail_default():
|
|
384
384
|
# ...
|
|
@@ -907,8 +907,10 @@ async def asyncnullcontext(*args, **kwargs):
|
|
|
907
907
|
Note that for Python 3.10+ you can use contextlib.nullcontext() instead.
|
|
908
908
|
|
|
909
909
|
Usage:
|
|
910
|
+
```python notest
|
|
910
911
|
async with asyncnullcontext():
|
|
911
912
|
pass
|
|
913
|
+
```
|
|
912
914
|
"""
|
|
913
915
|
yield
|
|
914
916
|
|
modal/_utils/blob_utils.py
CHANGED
|
@@ -18,6 +18,7 @@ from typing import (
|
|
|
18
18
|
)
|
|
19
19
|
from urllib.parse import urlparse
|
|
20
20
|
|
|
21
|
+
from google.protobuf.timestamp_pb2 import Timestamp
|
|
21
22
|
from typing_extensions import Self
|
|
22
23
|
|
|
23
24
|
from modal_proto import api_pb2
|
|
@@ -451,6 +452,7 @@ class FileUploadSpec:
|
|
|
451
452
|
size: int
|
|
452
453
|
content: bytes | None = None # Set for very small files to avoid double-read
|
|
453
454
|
exists_in_store: bool | None = None # set after existence check
|
|
455
|
+
checked_at: Timestamp | None = None # server time of the existence check, set alongside exists_in_store
|
|
454
456
|
|
|
455
457
|
def read_content(self) -> bytes:
|
|
456
458
|
"""Read content from source."""
|
modal/cli/_logs.py
CHANGED
|
@@ -19,7 +19,7 @@ _SOURCE_OPTIONS = {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
|
|
22
|
-
def _parse_time_arg(value: str | None, default: datetime, *, now: datetime
|
|
22
|
+
def _parse_time_arg(value: str | None, default: datetime, *, now: datetime) -> datetime:
|
|
23
23
|
"""Parse a time argument that can be a relative duration (e.g. '2h', '30m') or ISO 8601 datetime.
|
|
24
24
|
|
|
25
25
|
Naive datetime values are interpreted in the user's local timezone.
|
|
@@ -33,7 +33,7 @@ def _parse_time_arg(value: str | None, default: datetime, *, now: datetime | Non
|
|
|
33
33
|
except ValueError:
|
|
34
34
|
pass
|
|
35
35
|
else:
|
|
36
|
-
return
|
|
36
|
+
return now - duration
|
|
37
37
|
|
|
38
38
|
try:
|
|
39
39
|
dt = datetime.fromisoformat(value)
|
|
@@ -62,7 +62,7 @@ def _parse_fetch_range(since: str | None, until: str | None) -> tuple[datetime,
|
|
|
62
62
|
try:
|
|
63
63
|
duration = parse_duration(value)
|
|
64
64
|
except ValueError:
|
|
65
|
-
result = _parse_time_arg(value, default=default)
|
|
65
|
+
result = _parse_time_arg(value, default=default, now=now)
|
|
66
66
|
else:
|
|
67
67
|
result = now - duration
|
|
68
68
|
if result.astimezone(timezone.utc).microsecond:
|
|
@@ -124,8 +124,8 @@ async def _run_logs_command(
|
|
|
124
124
|
return
|
|
125
125
|
|
|
126
126
|
now = datetime.now(timezone.utc)
|
|
127
|
-
since_dt = _parse_time_arg(since, default=now) if since else None
|
|
128
|
-
until_dt = _parse_time_arg(until, default=now) if until else None
|
|
127
|
+
since_dt = _parse_time_arg(since, default=now, now=now) if since else None
|
|
128
|
+
until_dt = _parse_time_arg(until, default=now, now=now) if until else None
|
|
129
129
|
if since_dt is not None:
|
|
130
130
|
effective_until = until_dt or now
|
|
131
131
|
if since_dt >= effective_until:
|
modal/cli/container.py
CHANGED
|
@@ -248,8 +248,8 @@ async def logs(
|
|
|
248
248
|
since_dt = default_since_dt
|
|
249
249
|
until_dt = default_until_dt
|
|
250
250
|
else:
|
|
251
|
-
since_dt = _parse_time_arg(since, default=default_since_dt)
|
|
252
|
-
until_dt = _parse_time_arg(until, default=default_until_dt)
|
|
251
|
+
since_dt = _parse_time_arg(since, default=default_since_dt, now=now)
|
|
252
|
+
until_dt = _parse_time_arg(until, default=default_until_dt, now=now)
|
|
253
253
|
|
|
254
254
|
if since is not None and until is not None and since_dt >= until_dt:
|
|
255
255
|
# User provided both --since and --until, but did so incorrectly
|
modal/cli/function.py
CHANGED
|
@@ -7,6 +7,7 @@ from typing import Any, AsyncGenerator, Sequence, cast
|
|
|
7
7
|
|
|
8
8
|
import click
|
|
9
9
|
from click import UsageError
|
|
10
|
+
from rich.box import SIMPLE
|
|
10
11
|
from rich.table import Column, Table
|
|
11
12
|
from rich.text import Text
|
|
12
13
|
|
|
@@ -16,7 +17,7 @@ from modal._functions import _Function
|
|
|
16
17
|
from modal._logs import _fetch_time_range, _seconds_to_timestamp, _timestamp_to_seconds
|
|
17
18
|
from modal._object import _get_environment_name
|
|
18
19
|
from modal._utils.async_utils import async_map_ordered, synchronizer
|
|
19
|
-
from modal._utils.time_utils import parse_duration
|
|
20
|
+
from modal._utils.time_utils import parse_duration, timestamp_to_localized_str
|
|
20
21
|
from modal.client import _Client
|
|
21
22
|
from modal.exception import LogsFetchError, NotFoundError
|
|
22
23
|
from modal.output import OutputManager
|
|
@@ -50,10 +51,12 @@ from .utils import (
|
|
|
50
51
|
env_option,
|
|
51
52
|
grouped_utc_timestamp,
|
|
52
53
|
humanize_filesize,
|
|
54
|
+
parse_int,
|
|
53
55
|
)
|
|
54
56
|
|
|
55
57
|
function_cli = ModalGroup(name="function", help="Inspect Modal Functions.")
|
|
56
58
|
|
|
59
|
+
|
|
57
60
|
_INPUT_METRIC_ORDER = ("Execution time (s)", "End-to-end latency (s)")
|
|
58
61
|
_CONTAINER_METRIC_ORDER = (
|
|
59
62
|
"Startup time (s)",
|
|
@@ -705,8 +708,8 @@ async def stats(
|
|
|
705
708
|
```
|
|
706
709
|
"""
|
|
707
710
|
now = datetime.now(timezone.utc)
|
|
708
|
-
until_dt = _parse_time_arg(until, default=now)
|
|
709
|
-
since_dt = _parse_time_arg(since, default=until_dt - _DEFAULT_STATS_WINDOW)
|
|
711
|
+
until_dt = _parse_time_arg(until, default=now, now=now)
|
|
712
|
+
since_dt = _parse_time_arg(since, default=until_dt - _DEFAULT_STATS_WINDOW, now=now)
|
|
710
713
|
if since_dt >= until_dt:
|
|
711
714
|
raise UsageError("--since must be before --until.")
|
|
712
715
|
|
|
@@ -1288,6 +1291,241 @@ async def variants(
|
|
|
1288
1291
|
)
|
|
1289
1292
|
|
|
1290
1293
|
|
|
1294
|
+
autoscaler_cli = ModalGroup(name="autoscaler", help="View and Configure Autoscaler settings for Modal Functions.")
|
|
1295
|
+
function_cli.add_command(autoscaler_cli)
|
|
1296
|
+
|
|
1297
|
+
|
|
1298
|
+
@autoscaler_cli.command("settings", no_args_is_help=True)
|
|
1299
|
+
@click.argument("function_identifier", metavar="FUNCTION")
|
|
1300
|
+
@click.option("--json", "json", is_flag=True, default=False, help="Output as JSON.")
|
|
1301
|
+
@env_option
|
|
1302
|
+
@synchronizer.create_blocking
|
|
1303
|
+
async def autoscaler_info(function_identifier: str, json: bool = False, env: str | None = None):
|
|
1304
|
+
"""Show the current Autsocaling settings for the provided Function.
|
|
1305
|
+
|
|
1306
|
+
In addition to the current settings, this command also shows any active overrides to these
|
|
1307
|
+
settings, along with metadata about who overrode the setting, and when. FUNCTION may be a
|
|
1308
|
+
Function ID or a deployed Function name in the form ``APP_NAME/FUNCTION_NAME``.
|
|
1309
|
+
|
|
1310
|
+
If a setting's value is the default and not overridden, its value is omitted in the output.
|
|
1311
|
+
|
|
1312
|
+
Examples:
|
|
1313
|
+
|
|
1314
|
+
Show autoscaling settings for a deployed Function:
|
|
1315
|
+
|
|
1316
|
+
```
|
|
1317
|
+
modal function autoscaler settings my-app/my-function
|
|
1318
|
+
```
|
|
1319
|
+
|
|
1320
|
+
Show autoscaling settings using a Function ID:
|
|
1321
|
+
|
|
1322
|
+
```
|
|
1323
|
+
modal function autoscaler settings fu-1234
|
|
1324
|
+
```
|
|
1325
|
+
"""
|
|
1326
|
+
|
|
1327
|
+
environment_name = _get_environment_name(ensure_env(env))
|
|
1328
|
+
client = await _Client.from_env()
|
|
1329
|
+
function_id, handle_metadata, _ = await _resolve_function_id(
|
|
1330
|
+
client,
|
|
1331
|
+
function_identifier,
|
|
1332
|
+
environment_name,
|
|
1333
|
+
object_type="Function",
|
|
1334
|
+
command="autoscaler settings",
|
|
1335
|
+
)
|
|
1336
|
+
|
|
1337
|
+
if not handle_metadata.base_function_id and len(handle_metadata.class_parameter_info.schema) > 0:
|
|
1338
|
+
raise ValueError(
|
|
1339
|
+
f"`{function_identifier}` is a modal.Cls with parameters. Provide a specific "
|
|
1340
|
+
"parameterization via a Function ID. See `modal function variants`."
|
|
1341
|
+
)
|
|
1342
|
+
|
|
1343
|
+
autoscaler_response = await client._stub.FunctionGetSchedulingParams(
|
|
1344
|
+
api_pb2.FunctionGetSchedulingParamsRequest(function_id=function_id)
|
|
1345
|
+
)
|
|
1346
|
+
|
|
1347
|
+
autoscaler_settings = FunctionAutoscalerSettings._from_proto(autoscaler_response.autoscaler_configuration.settings)
|
|
1348
|
+
override_events = autoscaler_response.autoscaler_configuration.override_events
|
|
1349
|
+
|
|
1350
|
+
output_manager = OutputManager.get()
|
|
1351
|
+
|
|
1352
|
+
if json:
|
|
1353
|
+
current_settings = dataclasses.asdict(autoscaler_settings)
|
|
1354
|
+
|
|
1355
|
+
override_events_json: list[dict[str, str]] = []
|
|
1356
|
+
for k, v in override_events.items():
|
|
1357
|
+
if v.user_id:
|
|
1358
|
+
obj = {
|
|
1359
|
+
"setting": k,
|
|
1360
|
+
"updated_at": str(v.timestamp),
|
|
1361
|
+
"actor_name": v.requested_by,
|
|
1362
|
+
"actor_id": v.user_id,
|
|
1363
|
+
"actor_type": "user",
|
|
1364
|
+
}
|
|
1365
|
+
elif v.service_user_id:
|
|
1366
|
+
obj = {
|
|
1367
|
+
"setting": k,
|
|
1368
|
+
"updated_at": str(v.timestamp),
|
|
1369
|
+
"actor_name": v.requested_by,
|
|
1370
|
+
"actor_id": v.service_user_id,
|
|
1371
|
+
"actor_type": "service-user",
|
|
1372
|
+
}
|
|
1373
|
+
else:
|
|
1374
|
+
obj = {
|
|
1375
|
+
"setting": k,
|
|
1376
|
+
"updated_at": str(v.timestamp),
|
|
1377
|
+
"actor_id": "unknown",
|
|
1378
|
+
"actor_name": "unknown",
|
|
1379
|
+
"actor_type": "unknown",
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
override_events_json.append(obj)
|
|
1383
|
+
|
|
1384
|
+
output_manager.print_json(
|
|
1385
|
+
json_lib.dumps(
|
|
1386
|
+
{
|
|
1387
|
+
"current_settings": current_settings,
|
|
1388
|
+
"override_events": override_events_json,
|
|
1389
|
+
}
|
|
1390
|
+
)
|
|
1391
|
+
)
|
|
1392
|
+
|
|
1393
|
+
return
|
|
1394
|
+
|
|
1395
|
+
human_readable = {
|
|
1396
|
+
"min_containers": "Min Containers",
|
|
1397
|
+
"max_containers": "Max Containers",
|
|
1398
|
+
"buffer_containers": "Buffer Containers",
|
|
1399
|
+
"scaledown_window": "Scaledown Window",
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
if override_events:
|
|
1403
|
+
t = Table("Setting", "Value", "Overridden By", box=SIMPLE)
|
|
1404
|
+
else:
|
|
1405
|
+
t = Table("Setting", "Value", box=SIMPLE)
|
|
1406
|
+
|
|
1407
|
+
for key, pretty in human_readable.items():
|
|
1408
|
+
value = getattr(autoscaler_settings, key)
|
|
1409
|
+
value_str = str(value)
|
|
1410
|
+
|
|
1411
|
+
override_meta = "-"
|
|
1412
|
+
if key in override_events:
|
|
1413
|
+
override = override_events[key]
|
|
1414
|
+
override_meta = f"{override.requested_by} at {timestamp_to_localized_str(override.timestamp, isotz=False)}"
|
|
1415
|
+
|
|
1416
|
+
if key == "scaledown_window":
|
|
1417
|
+
value_str = f"{value} seconds"
|
|
1418
|
+
|
|
1419
|
+
if key not in override_events and not value:
|
|
1420
|
+
value_str = "-"
|
|
1421
|
+
|
|
1422
|
+
if override_events:
|
|
1423
|
+
t.add_row(pretty, value_str, override_meta)
|
|
1424
|
+
else:
|
|
1425
|
+
t.add_row(pretty, value_str)
|
|
1426
|
+
|
|
1427
|
+
output_manager.print(t)
|
|
1428
|
+
|
|
1429
|
+
|
|
1430
|
+
@autoscaler_cli.command("update", no_args_is_help=True)
|
|
1431
|
+
@click.argument("function_identifier", metavar="FUNCTION")
|
|
1432
|
+
@click.option("--max-containers", "max_containers", default=None, help="Maximum concurrent containers.")
|
|
1433
|
+
@click.option("--min-containers", "min_containers", default=None, help="Minimum number of containers to keep running.")
|
|
1434
|
+
@click.option(
|
|
1435
|
+
"--buffer-containers",
|
|
1436
|
+
"buffer_containers",
|
|
1437
|
+
default=None,
|
|
1438
|
+
help="Extra containers to keep warm beyond current demand.",
|
|
1439
|
+
)
|
|
1440
|
+
@click.option(
|
|
1441
|
+
"--scaledown-window",
|
|
1442
|
+
"scaledown_window",
|
|
1443
|
+
default=None,
|
|
1444
|
+
help="Maximum duration (in seconds) idle containers wait before scaling down.",
|
|
1445
|
+
)
|
|
1446
|
+
@env_option
|
|
1447
|
+
@synchronizer.create_blocking
|
|
1448
|
+
async def autoscaler_update(
|
|
1449
|
+
function_identifier: str,
|
|
1450
|
+
max_containers: str | None = None,
|
|
1451
|
+
min_containers: str | None = None,
|
|
1452
|
+
buffer_containers: str | None = None,
|
|
1453
|
+
scaledown_window: str | None = None,
|
|
1454
|
+
env: str | None = None,
|
|
1455
|
+
):
|
|
1456
|
+
"""Update the current Autsocaling settings for the provided Function.
|
|
1457
|
+
|
|
1458
|
+
FUNCTION may be a Function ID or a deployed Function name in the form ``APP_NAME/FUNCTION_NAME``.
|
|
1459
|
+
|
|
1460
|
+
Settings can be modified using the `--max-containers`, `--min-containers`, `--buffer-containers`,
|
|
1461
|
+
and `--scaledown-window` options.
|
|
1462
|
+
|
|
1463
|
+
If provided, Max Containers should be a positive integer, Min/Buffer Containers should be
|
|
1464
|
+
nonnegative integers, and Scaledown window should be a positive integer measured in seconds.
|
|
1465
|
+
|
|
1466
|
+
Examples:
|
|
1467
|
+
|
|
1468
|
+
Update Max Containers for a deployed Function:
|
|
1469
|
+
|
|
1470
|
+
```
|
|
1471
|
+
modal function autoscaler update my-app/my-function --max-containers 10
|
|
1472
|
+
```
|
|
1473
|
+
|
|
1474
|
+
Update both Min and Buffer Containers for a Function by ID:
|
|
1475
|
+
|
|
1476
|
+
```
|
|
1477
|
+
modal function autoscaler update fu-1234 --min-containers 3 --buffer-containers 2
|
|
1478
|
+
```
|
|
1479
|
+
"""
|
|
1480
|
+
|
|
1481
|
+
environment_name = _get_environment_name(ensure_env(env))
|
|
1482
|
+
client = await _Client.from_env()
|
|
1483
|
+
|
|
1484
|
+
updates: dict[str, int] = {}
|
|
1485
|
+
for k, v in {
|
|
1486
|
+
"min_containers": min_containers,
|
|
1487
|
+
"max_containers": max_containers,
|
|
1488
|
+
"buffer_containers": buffer_containers,
|
|
1489
|
+
"scaledown_window": scaledown_window,
|
|
1490
|
+
}.items():
|
|
1491
|
+
if v is None:
|
|
1492
|
+
continue
|
|
1493
|
+
|
|
1494
|
+
try:
|
|
1495
|
+
updates[k] = parse_int(
|
|
1496
|
+
v,
|
|
1497
|
+
allow_negative=False,
|
|
1498
|
+
allow_zero=k in {"min_containers", "buffer_containers"},
|
|
1499
|
+
)
|
|
1500
|
+
except ValueError as e:
|
|
1501
|
+
option_name = f"--{k.replace('_', '-')}"
|
|
1502
|
+
raise click.UsageError(f"Invalid input for {option_name!r}: {e}")
|
|
1503
|
+
|
|
1504
|
+
output_manager = OutputManager.get()
|
|
1505
|
+
if not updates:
|
|
1506
|
+
output_manager.print("No autoscaler settings specified.")
|
|
1507
|
+
return
|
|
1508
|
+
|
|
1509
|
+
function_id, handle_metadata, _ = await _resolve_function_id(
|
|
1510
|
+
client,
|
|
1511
|
+
function_identifier,
|
|
1512
|
+
environment_name,
|
|
1513
|
+
object_type="Function",
|
|
1514
|
+
command="autoscaler update",
|
|
1515
|
+
)
|
|
1516
|
+
|
|
1517
|
+
if not handle_metadata.base_function_id and len(handle_metadata.class_parameter_info.schema) > 0:
|
|
1518
|
+
raise ValueError(
|
|
1519
|
+
f"`{function_identifier}` is a modal.Cls with parameters. Provide a specific "
|
|
1520
|
+
"parameterization via a Function ID. See `modal function variants`."
|
|
1521
|
+
)
|
|
1522
|
+
|
|
1523
|
+
f: _Function = _Function._new_hydrated(function_id, client, handle_metadata)
|
|
1524
|
+
await f.update_autoscaler(**updates)
|
|
1525
|
+
|
|
1526
|
+
output_manager.print(f"[green]✓[/green] Updated autoscaler settings for Function {handle_metadata.function_name}")
|
|
1527
|
+
|
|
1528
|
+
|
|
1291
1529
|
@function_cli.command("metrics", no_args_is_help=True)
|
|
1292
1530
|
@click.argument("identifier", metavar="FUNCTION")
|
|
1293
1531
|
@click.option("--since", help="Start time: ISO 8601 or relative duration (e.g. 2h). Naive times are local.")
|
modal/cli/server.py
CHANGED
|
@@ -9,6 +9,7 @@ from typing import AsyncGenerator, Sequence, cast
|
|
|
9
9
|
|
|
10
10
|
import click
|
|
11
11
|
from click import UsageError
|
|
12
|
+
from rich.box import SIMPLE
|
|
12
13
|
from rich.table import Column, Table
|
|
13
14
|
from rich.text import Text
|
|
14
15
|
|
|
@@ -17,7 +18,7 @@ from modal._logs import _fetch_time_range, _seconds_to_timestamp, _timestamp_to_
|
|
|
17
18
|
from modal._object import _get_environment_name
|
|
18
19
|
from modal._server import _Server
|
|
19
20
|
from modal._utils.async_utils import async_map_ordered, synchronizer
|
|
20
|
-
from modal._utils.time_utils import parse_duration
|
|
21
|
+
from modal._utils.time_utils import parse_duration, timestamp_to_localized_str
|
|
21
22
|
from modal.cli.utils import humanize_filesize
|
|
22
23
|
from modal.client import _Client
|
|
23
24
|
from modal.exception import LogsFetchError, NotFoundError
|
|
@@ -51,6 +52,7 @@ from .utils import (
|
|
|
51
52
|
display_table,
|
|
52
53
|
env_option,
|
|
53
54
|
grouped_utc_timestamp,
|
|
55
|
+
parse_int,
|
|
54
56
|
)
|
|
55
57
|
|
|
56
58
|
server_cli = ModalGroup(name="server", help="Inspect Modal Servers.")
|
|
@@ -671,8 +673,8 @@ def _render_inference(history: api_pb2.ServerGetTimeRangeStatsResponse, use_colo
|
|
|
671
673
|
|
|
672
674
|
def _server_stats_time_range(since: str | None, until: str | None) -> tuple[datetime, datetime, timedelta]:
|
|
673
675
|
now = datetime.now(timezone.utc)
|
|
674
|
-
until_dt = _parse_time_arg(until, default=now)
|
|
675
|
-
since_dt = _parse_time_arg(since, default=until_dt - _DEFAULT_STATS_WINDOW)
|
|
676
|
+
until_dt = _parse_time_arg(until, default=now, now=now)
|
|
677
|
+
since_dt = _parse_time_arg(since, default=until_dt - _DEFAULT_STATS_WINDOW, now=now)
|
|
676
678
|
if since_dt >= until_dt:
|
|
677
679
|
raise UsageError("--since must be before --until.")
|
|
678
680
|
|
|
@@ -1086,6 +1088,281 @@ async def requests(
|
|
|
1086
1088
|
)
|
|
1087
1089
|
|
|
1088
1090
|
|
|
1091
|
+
autoscaler_cli = ModalGroup(name="autoscaler", help="View and Configure Autoscaler settings for Modal Servers.")
|
|
1092
|
+
server_cli.add_command(autoscaler_cli)
|
|
1093
|
+
|
|
1094
|
+
|
|
1095
|
+
@autoscaler_cli.command("settings", no_args_is_help=True)
|
|
1096
|
+
@click.argument("server_identifier", metavar="FUNCTION")
|
|
1097
|
+
@click.option("--json", "json", is_flag=True, default=False, help="Output as JSON.")
|
|
1098
|
+
@env_option
|
|
1099
|
+
@synchronizer.create_blocking
|
|
1100
|
+
async def autoscaler_info(server_identifier: str, json: bool = False, env: str | None = None):
|
|
1101
|
+
"""Show the current Autsocaling settings for the provided Server.
|
|
1102
|
+
|
|
1103
|
+
In addition to the current settings, this command also shows any active overrides to these
|
|
1104
|
+
settings, along with metadata about who overrode the setting, and when. SERVER may be a
|
|
1105
|
+
Server ID or a deployed Server name in the form ``APP_NAME/SERVER_NAME``.
|
|
1106
|
+
|
|
1107
|
+
If a setting's value is the default and not overridden, its value is omitted in the output.
|
|
1108
|
+
|
|
1109
|
+
Examples:
|
|
1110
|
+
|
|
1111
|
+
Show autoscaling settings for a deployed Server:
|
|
1112
|
+
|
|
1113
|
+
```
|
|
1114
|
+
modal server autoscaler settings my-app/my-server
|
|
1115
|
+
```
|
|
1116
|
+
|
|
1117
|
+
Show autoscaling settings using an ID:
|
|
1118
|
+
|
|
1119
|
+
```
|
|
1120
|
+
modal server autoscaler settings fu-1234
|
|
1121
|
+
```
|
|
1122
|
+
"""
|
|
1123
|
+
|
|
1124
|
+
environment_name = _get_environment_name(ensure_env(env))
|
|
1125
|
+
client = await _Client.from_env()
|
|
1126
|
+
function_id, _, _ = await _resolve_function_id(
|
|
1127
|
+
client,
|
|
1128
|
+
server_identifier,
|
|
1129
|
+
environment_name,
|
|
1130
|
+
object_type="Server",
|
|
1131
|
+
command="autoscaler settings",
|
|
1132
|
+
)
|
|
1133
|
+
|
|
1134
|
+
autoscaler_response = await client._stub.FunctionGetSchedulingParams(
|
|
1135
|
+
api_pb2.FunctionGetSchedulingParamsRequest(function_id=function_id)
|
|
1136
|
+
)
|
|
1137
|
+
|
|
1138
|
+
autoscaler_settings = ServerAutoscalerSettings._from_proto(autoscaler_response.autoscaler_configuration.settings)
|
|
1139
|
+
override_events = autoscaler_response.autoscaler_configuration.override_events
|
|
1140
|
+
|
|
1141
|
+
output_manager = OutputManager.get()
|
|
1142
|
+
|
|
1143
|
+
if json:
|
|
1144
|
+
current_settings = dataclasses.asdict(autoscaler_settings)
|
|
1145
|
+
if current_settings["target_concurrency"] == 0:
|
|
1146
|
+
current_settings["target_concurrency"] = None
|
|
1147
|
+
|
|
1148
|
+
override_events_json: list[dict[str, str]] = []
|
|
1149
|
+
for k, v in override_events.items():
|
|
1150
|
+
if k == "target_concurrency_float":
|
|
1151
|
+
continue
|
|
1152
|
+
|
|
1153
|
+
if v.user_id:
|
|
1154
|
+
obj = {
|
|
1155
|
+
"setting": k,
|
|
1156
|
+
"updated_at": str(v.timestamp),
|
|
1157
|
+
"actor_name": v.requested_by,
|
|
1158
|
+
"actor_id": v.user_id,
|
|
1159
|
+
"actor_type": "user",
|
|
1160
|
+
}
|
|
1161
|
+
elif v.service_user_id:
|
|
1162
|
+
obj = {
|
|
1163
|
+
"setting": k,
|
|
1164
|
+
"updated_at": str(v.timestamp),
|
|
1165
|
+
"actor_name": v.requested_by,
|
|
1166
|
+
"actor_id": v.service_user_id,
|
|
1167
|
+
"actor_type": "service-user",
|
|
1168
|
+
}
|
|
1169
|
+
else:
|
|
1170
|
+
obj = {
|
|
1171
|
+
"setting": k,
|
|
1172
|
+
"updated_at": str(v.timestamp),
|
|
1173
|
+
"actor_id": "unknown",
|
|
1174
|
+
"actor_name": "unknown",
|
|
1175
|
+
"actor_type": "unknown",
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
override_events_json.append(obj)
|
|
1179
|
+
|
|
1180
|
+
output_manager.print_json(
|
|
1181
|
+
json_lib.dumps(
|
|
1182
|
+
{
|
|
1183
|
+
"current_settings": current_settings,
|
|
1184
|
+
"override_events": override_events_json,
|
|
1185
|
+
}
|
|
1186
|
+
)
|
|
1187
|
+
)
|
|
1188
|
+
|
|
1189
|
+
return
|
|
1190
|
+
|
|
1191
|
+
human_readable = {
|
|
1192
|
+
"min_containers": "Min Containers",
|
|
1193
|
+
"max_containers": "Max Containers",
|
|
1194
|
+
"buffer_containers": "Buffer Containers",
|
|
1195
|
+
"scaledown_window": "Scaledown Window",
|
|
1196
|
+
"scaleup_window": "Scaleup Window",
|
|
1197
|
+
"target_concurrency": "Target Concurrency",
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
if override_events:
|
|
1201
|
+
t = Table("Setting", "Value", "Overridden By", box=SIMPLE)
|
|
1202
|
+
else:
|
|
1203
|
+
t = Table("Setting", "Value", box=SIMPLE)
|
|
1204
|
+
|
|
1205
|
+
for key, pretty in human_readable.items():
|
|
1206
|
+
value = getattr(autoscaler_settings, key)
|
|
1207
|
+
value_str = str(value)
|
|
1208
|
+
|
|
1209
|
+
override_meta = "-"
|
|
1210
|
+
if key in override_events:
|
|
1211
|
+
override = override_events[key]
|
|
1212
|
+
override_meta = f"{override.requested_by} at {timestamp_to_localized_str(override.timestamp, isotz=False)}"
|
|
1213
|
+
|
|
1214
|
+
if key in {"scaledown_window", "scaleup_window"}:
|
|
1215
|
+
value_str = f"{value} seconds"
|
|
1216
|
+
|
|
1217
|
+
if key == "target_concurrency":
|
|
1218
|
+
value_str = f"{value} requests/container"
|
|
1219
|
+
|
|
1220
|
+
if not value and (key == "target_concurrency" or key not in override_events):
|
|
1221
|
+
value_str = "-"
|
|
1222
|
+
|
|
1223
|
+
if override_events:
|
|
1224
|
+
t.add_row(pretty, value_str, override_meta)
|
|
1225
|
+
else:
|
|
1226
|
+
t.add_row(pretty, value_str)
|
|
1227
|
+
|
|
1228
|
+
output_manager.print(t)
|
|
1229
|
+
|
|
1230
|
+
|
|
1231
|
+
@autoscaler_cli.command("update", no_args_is_help=True)
|
|
1232
|
+
@click.argument("server_identifier", metavar="SERVER")
|
|
1233
|
+
@click.option("--max-containers", "max_containers", default=None, help="Maximum concurrent containers.")
|
|
1234
|
+
@click.option("--min-containers", "min_containers", default=None, help="Minimum number of containers to keep running.")
|
|
1235
|
+
@click.option(
|
|
1236
|
+
"--buffer-containers",
|
|
1237
|
+
"buffer_containers",
|
|
1238
|
+
default=None,
|
|
1239
|
+
help="Extra containers to keep warm beyond current demand.",
|
|
1240
|
+
)
|
|
1241
|
+
@click.option(
|
|
1242
|
+
"--scaledown-window",
|
|
1243
|
+
"scaledown_window",
|
|
1244
|
+
default=None,
|
|
1245
|
+
help="Maximum duration (in seconds) idle containers wait before scaling down.",
|
|
1246
|
+
)
|
|
1247
|
+
@click.option(
|
|
1248
|
+
"--scaleup-window",
|
|
1249
|
+
"scaleup_window",
|
|
1250
|
+
default=None,
|
|
1251
|
+
help="Seconds of sustained demand required before scaling up new containers. ",
|
|
1252
|
+
)
|
|
1253
|
+
@click.option(
|
|
1254
|
+
"--target-concurrency",
|
|
1255
|
+
"target_concurrency",
|
|
1256
|
+
default=None,
|
|
1257
|
+
help="Target number of concurrent requests per container. May be fractional.",
|
|
1258
|
+
)
|
|
1259
|
+
@env_option
|
|
1260
|
+
@synchronizer.create_blocking
|
|
1261
|
+
async def autoscaler_update(
|
|
1262
|
+
server_identifier: str,
|
|
1263
|
+
max_containers: str | None = None,
|
|
1264
|
+
min_containers: str | None = None,
|
|
1265
|
+
buffer_containers: str | None = None,
|
|
1266
|
+
scaledown_window: str | None = None,
|
|
1267
|
+
scaleup_window: str | None = None,
|
|
1268
|
+
target_concurrency: str | None = None,
|
|
1269
|
+
env: str | None = None,
|
|
1270
|
+
):
|
|
1271
|
+
"""Update the current Autsocaling settings for the provided Server.
|
|
1272
|
+
|
|
1273
|
+
SERVER may be a Server ID or a deployed Server name in the form ``APP_NAME/SERVER_NAME``.
|
|
1274
|
+
|
|
1275
|
+
Settings can be modified using the `--max-containers`, `--min-containers`, `--buffer-containers`,
|
|
1276
|
+
`--scaledown-window`, `--scaleup-window`, and `--target-concurrency` options.
|
|
1277
|
+
|
|
1278
|
+
If provided, Max Containers should be a positive integer, Min and Buffer Containers should be
|
|
1279
|
+
nonnegative integers, Scaledown and Scaleup windows should be positive integers measured in seconds,
|
|
1280
|
+
and Target Concurrency should either be a positive floating point number, or the string "none".
|
|
1281
|
+
|
|
1282
|
+
Examples:
|
|
1283
|
+
|
|
1284
|
+
Update Max Containers for a deployed Server:
|
|
1285
|
+
|
|
1286
|
+
```
|
|
1287
|
+
modal server autoscaler update my-app/my-server --max-containers 10
|
|
1288
|
+
```
|
|
1289
|
+
|
|
1290
|
+
Update both Min Containers and Target Concurrency for a Server by ID:
|
|
1291
|
+
|
|
1292
|
+
```
|
|
1293
|
+
modal server autoscaler update fu-1234 --min-containers 3 --target-concurrency 2.5
|
|
1294
|
+
```
|
|
1295
|
+
|
|
1296
|
+
Set Target Concurrency to "none" (disabling autoscaling for the Server)
|
|
1297
|
+
|
|
1298
|
+
```
|
|
1299
|
+
modal server autoscaler update my-app/my-server --target-concurrency none
|
|
1300
|
+
```
|
|
1301
|
+
"""
|
|
1302
|
+
|
|
1303
|
+
environment_name = _get_environment_name(ensure_env(env))
|
|
1304
|
+
client = await _Client.from_env()
|
|
1305
|
+
|
|
1306
|
+
updates: dict[str, int | float] = {}
|
|
1307
|
+
for k, v in {
|
|
1308
|
+
"min_containers": min_containers,
|
|
1309
|
+
"max_containers": max_containers,
|
|
1310
|
+
"buffer_containers": buffer_containers,
|
|
1311
|
+
"scaledown_window": scaledown_window,
|
|
1312
|
+
"scaleup_window": scaleup_window,
|
|
1313
|
+
"target_concurrency": target_concurrency,
|
|
1314
|
+
}.items():
|
|
1315
|
+
if v is None:
|
|
1316
|
+
continue
|
|
1317
|
+
|
|
1318
|
+
if k == "target_concurrency":
|
|
1319
|
+
error_message = (
|
|
1320
|
+
f"Invalid input for '--target-concurrency': {v!r} must be a positive float or the string \"none\""
|
|
1321
|
+
)
|
|
1322
|
+
|
|
1323
|
+
if v == "none":
|
|
1324
|
+
updates[k] = 0
|
|
1325
|
+
continue
|
|
1326
|
+
|
|
1327
|
+
try:
|
|
1328
|
+
updates[k] = float(v)
|
|
1329
|
+
except ValueError as e:
|
|
1330
|
+
raise click.UsageError(error_message) from e
|
|
1331
|
+
|
|
1332
|
+
if updates[k] <= 0:
|
|
1333
|
+
raise click.UsageError(error_message)
|
|
1334
|
+
|
|
1335
|
+
continue
|
|
1336
|
+
|
|
1337
|
+
try:
|
|
1338
|
+
updates[k] = parse_int(
|
|
1339
|
+
v,
|
|
1340
|
+
allow_negative=False,
|
|
1341
|
+
allow_zero=k in {"min_containers", "buffer_containers"},
|
|
1342
|
+
)
|
|
1343
|
+
except ValueError as e:
|
|
1344
|
+
option_name = f"--{k.replace('_', '-')}"
|
|
1345
|
+
raise click.UsageError(f"Invalid input for {option_name!r}: {e}")
|
|
1346
|
+
|
|
1347
|
+
output_manager = OutputManager.get()
|
|
1348
|
+
if not updates:
|
|
1349
|
+
output_manager.print("No autoscaler settings specified.")
|
|
1350
|
+
return
|
|
1351
|
+
|
|
1352
|
+
function_id, handle_metadata, _ = await _resolve_function_id(
|
|
1353
|
+
client,
|
|
1354
|
+
server_identifier,
|
|
1355
|
+
environment_name,
|
|
1356
|
+
object_type="Server",
|
|
1357
|
+
command="autoscaler update",
|
|
1358
|
+
)
|
|
1359
|
+
|
|
1360
|
+
s = _Server._new_from_function(function_id, client, handle_metadata)
|
|
1361
|
+
await s.update_autoscaler(**updates) # type: ignore
|
|
1362
|
+
|
|
1363
|
+
output_manager.print(f"[green]✓[/green] Updated autoscaler settings for Server {handle_metadata.function_name}")
|
|
1364
|
+
|
|
1365
|
+
|
|
1089
1366
|
@server_cli.command("metrics", no_args_is_help=True)
|
|
1090
1367
|
@click.argument("identifier", metavar="SERVER")
|
|
1091
1368
|
@click.option("--since", help="Start time: ISO 8601 or relative duration (e.g. 2h). Naive times are local.")
|