dstack 0.19.17__py3-none-any.whl → 0.19.18__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 dstack might be problematic. Click here for more details.
- dstack/_internal/cli/services/configurators/fleet.py +13 -1
- dstack/_internal/core/backends/aws/compute.py +237 -18
- dstack/_internal/core/backends/base/compute.py +20 -2
- dstack/_internal/core/backends/cudo/compute.py +23 -9
- dstack/_internal/core/backends/gcp/compute.py +13 -7
- dstack/_internal/core/backends/lambdalabs/compute.py +2 -1
- dstack/_internal/core/compatibility/fleets.py +12 -11
- dstack/_internal/core/compatibility/gateways.py +9 -8
- dstack/_internal/core/compatibility/logs.py +4 -3
- dstack/_internal/core/compatibility/runs.py +17 -20
- dstack/_internal/core/compatibility/volumes.py +9 -8
- dstack/_internal/core/errors.py +4 -0
- dstack/_internal/core/models/common.py +7 -0
- dstack/_internal/core/services/diff.py +36 -3
- dstack/_internal/server/app.py +20 -0
- dstack/_internal/server/background/__init__.py +61 -37
- dstack/_internal/server/background/tasks/process_fleets.py +19 -3
- dstack/_internal/server/background/tasks/process_gateways.py +1 -1
- dstack/_internal/server/background/tasks/process_instances.py +13 -2
- dstack/_internal/server/background/tasks/process_placement_groups.py +4 -2
- dstack/_internal/server/background/tasks/process_running_jobs.py +14 -3
- dstack/_internal/server/background/tasks/process_runs.py +8 -4
- dstack/_internal/server/background/tasks/process_submitted_jobs.py +36 -7
- dstack/_internal/server/background/tasks/process_terminating_jobs.py +5 -3
- dstack/_internal/server/background/tasks/process_volumes.py +2 -2
- dstack/_internal/server/services/fleets.py +5 -4
- dstack/_internal/server/services/gateways/__init__.py +4 -2
- dstack/_internal/server/services/jobs/configurators/base.py +5 -1
- dstack/_internal/server/services/locking.py +101 -12
- dstack/_internal/server/services/runs.py +24 -40
- dstack/_internal/server/services/volumes.py +2 -2
- dstack/_internal/server/settings.py +18 -4
- dstack/_internal/server/statics/index.html +1 -1
- dstack/_internal/server/statics/{main-d151637af20f70b2e796.js → main-d1ac2e8c38ed5f08a114.js} +68 -64
- dstack/_internal/server/statics/{main-d151637af20f70b2e796.js.map → main-d1ac2e8c38ed5f08a114.js.map} +1 -1
- dstack/_internal/server/statics/{main-d48635d8fe670d53961c.css → main-d58fc0460cb0eae7cb5c.css} +1 -1
- dstack/_internal/server/testing/common.py +7 -3
- dstack/version.py +1 -1
- {dstack-0.19.17.dist-info → dstack-0.19.18.dist-info}/METADATA +11 -10
- {dstack-0.19.17.dist-info → dstack-0.19.18.dist-info}/RECORD +43 -43
- {dstack-0.19.17.dist-info → dstack-0.19.18.dist-info}/WHEEL +0 -0
- {dstack-0.19.17.dist-info → dstack-0.19.18.dist-info}/entry_points.txt +0 -0
- {dstack-0.19.17.dist-info → dstack-0.19.18.dist-info}/licenses/LICENSE.md +0 -0
|
@@ -19,7 +19,7 @@ from dstack._internal.core.models.runs import (
|
|
|
19
19
|
RunStatus,
|
|
20
20
|
RunTerminationReason,
|
|
21
21
|
)
|
|
22
|
-
from dstack._internal.server.db import get_session_ctx
|
|
22
|
+
from dstack._internal.server.db import get_db, get_session_ctx
|
|
23
23
|
from dstack._internal.server.models import JobModel, ProjectModel, RunModel
|
|
24
24
|
from dstack._internal.server.services.jobs import (
|
|
25
25
|
find_job,
|
|
@@ -41,6 +41,8 @@ from dstack._internal.utils import common
|
|
|
41
41
|
from dstack._internal.utils.logging import get_logger
|
|
42
42
|
|
|
43
43
|
logger = get_logger(__name__)
|
|
44
|
+
|
|
45
|
+
MIN_PROCESSING_INTERVAL = datetime.timedelta(seconds=5)
|
|
44
46
|
ROLLING_DEPLOYMENT_MAX_SURGE = 1 # at most one extra replica during rolling deployment
|
|
45
47
|
|
|
46
48
|
|
|
@@ -52,8 +54,8 @@ async def process_runs(batch_size: int = 1):
|
|
|
52
54
|
|
|
53
55
|
|
|
54
56
|
async def _process_next_run():
|
|
55
|
-
run_lock, run_lockset = get_locker().get_lockset(RunModel.__tablename__)
|
|
56
|
-
job_lock, job_lockset = get_locker().get_lockset(JobModel.__tablename__)
|
|
57
|
+
run_lock, run_lockset = get_locker(get_db().dialect_name).get_lockset(RunModel.__tablename__)
|
|
58
|
+
job_lock, job_lockset = get_locker(get_db().dialect_name).get_lockset(JobModel.__tablename__)
|
|
57
59
|
async with get_session_ctx() as session:
|
|
58
60
|
async with run_lock, job_lock:
|
|
59
61
|
res = await session.execute(
|
|
@@ -61,6 +63,8 @@ async def _process_next_run():
|
|
|
61
63
|
.where(
|
|
62
64
|
RunModel.status.not_in(RunStatus.finished_statuses()),
|
|
63
65
|
RunModel.id.not_in(run_lockset),
|
|
66
|
+
RunModel.last_processed_at
|
|
67
|
+
< common.get_current_datetime().replace(tzinfo=None) - MIN_PROCESSING_INTERVAL,
|
|
64
68
|
)
|
|
65
69
|
.order_by(RunModel.last_processed_at.asc())
|
|
66
70
|
.limit(1)
|
|
@@ -337,7 +341,7 @@ async def _process_active_run(session: AsyncSession, run_model: RunModel):
|
|
|
337
341
|
current_time - run_model.submitted_at.replace(tzinfo=datetime.timezone.utc)
|
|
338
342
|
).total_seconds()
|
|
339
343
|
logger.info(
|
|
340
|
-
"%s: run took %.2f seconds from
|
|
344
|
+
"%s: run took %.2f seconds from submission to provisioning.",
|
|
341
345
|
fmt(run_model),
|
|
342
346
|
submit_to_provision_duration,
|
|
343
347
|
)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import uuid
|
|
3
|
+
from datetime import datetime, timedelta
|
|
3
4
|
from typing import List, Optional, Tuple
|
|
4
5
|
|
|
5
6
|
from sqlalchemy import select
|
|
@@ -80,15 +81,35 @@ from dstack._internal.utils.logging import get_logger
|
|
|
80
81
|
logger = get_logger(__name__)
|
|
81
82
|
|
|
82
83
|
|
|
84
|
+
# Track when we last processed a job.
|
|
85
|
+
# This is needed for a trick:
|
|
86
|
+
# If no tasks were processed recently, we force batch_size 1.
|
|
87
|
+
# If there are lots of runs/jobs with same offers submitted,
|
|
88
|
+
# we warm up the cache instead of requesting the offers concurrently.
|
|
89
|
+
# Mostly useful when runs are submitted via API without getting run plan first.
|
|
90
|
+
BATCH_SIZE_RESET_TIMEOUT = timedelta(minutes=2)
|
|
91
|
+
last_processed_at: Optional[datetime] = None
|
|
92
|
+
|
|
93
|
+
|
|
83
94
|
async def process_submitted_jobs(batch_size: int = 1):
|
|
84
95
|
tasks = []
|
|
85
|
-
|
|
96
|
+
effective_batch_size = _get_effective_batch_size(batch_size)
|
|
97
|
+
for _ in range(effective_batch_size):
|
|
86
98
|
tasks.append(_process_next_submitted_job())
|
|
87
99
|
await asyncio.gather(*tasks)
|
|
88
100
|
|
|
89
101
|
|
|
102
|
+
def _get_effective_batch_size(batch_size: int) -> int:
|
|
103
|
+
if (
|
|
104
|
+
last_processed_at is None
|
|
105
|
+
or last_processed_at < common_utils.get_current_datetime() - BATCH_SIZE_RESET_TIMEOUT
|
|
106
|
+
):
|
|
107
|
+
return 1
|
|
108
|
+
return batch_size
|
|
109
|
+
|
|
110
|
+
|
|
90
111
|
async def _process_next_submitted_job():
|
|
91
|
-
lock, lockset = get_locker().get_lockset(JobModel.__tablename__)
|
|
112
|
+
lock, lockset = get_locker(get_db().dialect_name).get_lockset(JobModel.__tablename__)
|
|
92
113
|
async with get_session_ctx() as session:
|
|
93
114
|
async with lock:
|
|
94
115
|
res = await session.execute(
|
|
@@ -125,6 +146,8 @@ async def _process_next_submitted_job():
|
|
|
125
146
|
await _process_submitted_job(session=session, job_model=job_model)
|
|
126
147
|
finally:
|
|
127
148
|
lockset.difference_update([job_model_id])
|
|
149
|
+
global last_processed_at
|
|
150
|
+
last_processed_at = common_utils.get_current_datetime()
|
|
128
151
|
|
|
129
152
|
|
|
130
153
|
async def _process_submitted_job(session: AsyncSession, job_model: JobModel):
|
|
@@ -214,7 +237,9 @@ async def _process_submitted_job(session: AsyncSession, job_model: JobModel):
|
|
|
214
237
|
if get_db().dialect_name == "sqlite":
|
|
215
238
|
# Start new transaction to see committed changes after lock
|
|
216
239
|
await session.commit()
|
|
217
|
-
async with get_locker().lock_ctx(
|
|
240
|
+
async with get_locker(get_db().dialect_name).lock_ctx(
|
|
241
|
+
InstanceModel.__tablename__, instances_ids
|
|
242
|
+
):
|
|
218
243
|
# If another job freed the instance but is still trying to detach volumes,
|
|
219
244
|
# do not provision on it to prevent attaching volumes that are currently detaching.
|
|
220
245
|
detaching_instances_ids = await get_instances_ids_with_detaching_volumes(session)
|
|
@@ -243,8 +268,10 @@ async def _process_submitted_job(session: AsyncSession, job_model: JobModel):
|
|
|
243
268
|
)
|
|
244
269
|
job_model.instance_assigned = True
|
|
245
270
|
job_model.last_processed_at = common_utils.get_current_datetime()
|
|
246
|
-
|
|
247
|
-
|
|
271
|
+
if len(pool_instances) > 0:
|
|
272
|
+
await session.commit()
|
|
273
|
+
return
|
|
274
|
+
# If no instances were locked, we can proceed in the same transaction.
|
|
248
275
|
|
|
249
276
|
if job_model.instance is not None:
|
|
250
277
|
res = await session.execute(
|
|
@@ -334,7 +361,7 @@ async def _process_submitted_job(session: AsyncSession, job_model: JobModel):
|
|
|
334
361
|
.order_by(VolumeModel.id) # take locks in order
|
|
335
362
|
.with_for_update(key_share=True)
|
|
336
363
|
)
|
|
337
|
-
async with get_locker().lock_ctx(VolumeModel.__tablename__, volumes_ids):
|
|
364
|
+
async with get_locker(get_db().dialect_name).lock_ctx(VolumeModel.__tablename__, volumes_ids):
|
|
338
365
|
if len(volume_models) > 0:
|
|
339
366
|
await _attach_volumes(
|
|
340
367
|
session=session,
|
|
@@ -527,7 +554,9 @@ async def _get_next_instance_num(session: AsyncSession, fleet_model: FleetModel)
|
|
|
527
554
|
if len(fleet_model.instances) == 0:
|
|
528
555
|
# No instances means the fleet is not in the db yet, so don't lock.
|
|
529
556
|
return 0
|
|
530
|
-
async with get_locker().lock_ctx(
|
|
557
|
+
async with get_locker(get_db().dialect_name).lock_ctx(
|
|
558
|
+
FleetModel.__tablename__, [fleet_model.id]
|
|
559
|
+
):
|
|
531
560
|
fleet_model = (
|
|
532
561
|
(
|
|
533
562
|
await session.execute(
|
|
@@ -5,7 +5,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
5
5
|
from sqlalchemy.orm import joinedload, lazyload
|
|
6
6
|
|
|
7
7
|
from dstack._internal.core.models.runs import JobStatus
|
|
8
|
-
from dstack._internal.server.db import get_session_ctx
|
|
8
|
+
from dstack._internal.server.db import get_db, get_session_ctx
|
|
9
9
|
from dstack._internal.server.models import (
|
|
10
10
|
InstanceModel,
|
|
11
11
|
JobModel,
|
|
@@ -32,8 +32,10 @@ async def process_terminating_jobs(batch_size: int = 1):
|
|
|
32
32
|
|
|
33
33
|
|
|
34
34
|
async def _process_next_terminating_job():
|
|
35
|
-
job_lock, job_lockset = get_locker().get_lockset(JobModel.__tablename__)
|
|
36
|
-
instance_lock, instance_lockset = get_locker().get_lockset(
|
|
35
|
+
job_lock, job_lockset = get_locker(get_db().dialect_name).get_lockset(JobModel.__tablename__)
|
|
36
|
+
instance_lock, instance_lockset = get_locker(get_db().dialect_name).get_lockset(
|
|
37
|
+
InstanceModel.__tablename__
|
|
38
|
+
)
|
|
37
39
|
async with get_session_ctx() as session:
|
|
38
40
|
async with job_lock, instance_lock:
|
|
39
41
|
res = await session.execute(
|
|
@@ -5,7 +5,7 @@ from sqlalchemy.orm import joinedload
|
|
|
5
5
|
from dstack._internal.core.backends.base.compute import ComputeWithVolumeSupport
|
|
6
6
|
from dstack._internal.core.errors import BackendError, BackendNotAvailable
|
|
7
7
|
from dstack._internal.core.models.volumes import VolumeStatus
|
|
8
|
-
from dstack._internal.server.db import get_session_ctx
|
|
8
|
+
from dstack._internal.server.db import get_db, get_session_ctx
|
|
9
9
|
from dstack._internal.server.models import (
|
|
10
10
|
InstanceModel,
|
|
11
11
|
ProjectModel,
|
|
@@ -22,7 +22,7 @@ logger = get_logger(__name__)
|
|
|
22
22
|
|
|
23
23
|
|
|
24
24
|
async def process_submitted_volumes():
|
|
25
|
-
lock, lockset = get_locker().get_lockset(VolumeModel.__tablename__)
|
|
25
|
+
lock, lockset = get_locker(get_db().dialect_name).get_lockset(VolumeModel.__tablename__)
|
|
26
26
|
async with get_session_ctx() as session:
|
|
27
27
|
async with lock:
|
|
28
28
|
res = await session.execute(
|
|
@@ -362,7 +362,7 @@ async def create_fleet(
|
|
|
362
362
|
select(func.pg_advisory_xact_lock(string_to_lock_id(lock_namespace)))
|
|
363
363
|
)
|
|
364
364
|
|
|
365
|
-
lock, _ = get_locker().get_lockset(lock_namespace)
|
|
365
|
+
lock, _ = get_locker(get_db().dialect_name).get_lockset(lock_namespace)
|
|
366
366
|
async with lock:
|
|
367
367
|
if spec.configuration.name is not None:
|
|
368
368
|
fleet_model = await get_project_fleet_model_by_name(
|
|
@@ -516,11 +516,12 @@ async def delete_fleets(
|
|
|
516
516
|
await session.commit()
|
|
517
517
|
logger.info("Deleting fleets: %s", [v.name for v in fleet_models])
|
|
518
518
|
async with (
|
|
519
|
-
get_locker().lock_ctx(FleetModel.__tablename__, fleets_ids),
|
|
520
|
-
get_locker().lock_ctx(InstanceModel.__tablename__, instances_ids),
|
|
519
|
+
get_locker(get_db().dialect_name).lock_ctx(FleetModel.__tablename__, fleets_ids),
|
|
520
|
+
get_locker(get_db().dialect_name).lock_ctx(InstanceModel.__tablename__, instances_ids),
|
|
521
521
|
):
|
|
522
522
|
# Refetch after lock
|
|
523
|
-
# TODO
|
|
523
|
+
# TODO: Lock instances with FOR UPDATE?
|
|
524
|
+
# TODO: Do not lock fleet when deleting only instances
|
|
524
525
|
res = await session.execute(
|
|
525
526
|
select(FleetModel)
|
|
526
527
|
.where(
|
|
@@ -162,7 +162,7 @@ async def create_gateway(
|
|
|
162
162
|
select(func.pg_advisory_xact_lock(string_to_lock_id(lock_namespace)))
|
|
163
163
|
)
|
|
164
164
|
|
|
165
|
-
lock, _ = get_locker().get_lockset(lock_namespace)
|
|
165
|
+
lock, _ = get_locker(get_db().dialect_name).get_lockset(lock_namespace)
|
|
166
166
|
async with lock:
|
|
167
167
|
if configuration.name is None:
|
|
168
168
|
configuration.name = await generate_gateway_name(session=session, project=project)
|
|
@@ -229,7 +229,9 @@ async def delete_gateways(
|
|
|
229
229
|
gateways_ids = sorted([g.id for g in gateway_models])
|
|
230
230
|
await session.commit()
|
|
231
231
|
logger.info("Deleting gateways: %s", [g.name for g in gateway_models])
|
|
232
|
-
async with get_locker().lock_ctx(
|
|
232
|
+
async with get_locker(get_db().dialect_name).lock_ctx(
|
|
233
|
+
GatewayModel.__tablename__, gateways_ids
|
|
234
|
+
):
|
|
233
235
|
# Refetch after lock
|
|
234
236
|
res = await session.execute(
|
|
235
237
|
select(GatewayModel)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import shlex
|
|
2
2
|
import sys
|
|
3
|
+
import threading
|
|
3
4
|
from abc import ABC, abstractmethod
|
|
4
5
|
from pathlib import PurePosixPath
|
|
5
6
|
from typing import Dict, List, Optional, Union
|
|
@@ -354,7 +355,10 @@ def _join_shell_commands(commands: List[str]) -> str:
|
|
|
354
355
|
return " && ".join(commands)
|
|
355
356
|
|
|
356
357
|
|
|
357
|
-
@cached(
|
|
358
|
+
@cached(
|
|
359
|
+
cache=TTLCache(maxsize=2048, ttl=80),
|
|
360
|
+
lock=threading.Lock(),
|
|
361
|
+
)
|
|
358
362
|
def _get_image_config(image: str, registry_auth: Optional[RegistryAuth]) -> ImageConfig:
|
|
359
363
|
try:
|
|
360
364
|
return get_image_config(image, registry_auth).config
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
+
import collections.abc
|
|
2
3
|
import hashlib
|
|
4
|
+
from abc import abstractmethod
|
|
3
5
|
from asyncio import Lock
|
|
4
6
|
from contextlib import asynccontextmanager
|
|
5
|
-
from typing import AsyncGenerator,
|
|
7
|
+
from typing import AsyncGenerator, Iterable, Iterator, Protocol, TypeVar, Union
|
|
6
8
|
|
|
7
9
|
from sqlalchemy import func, select
|
|
8
10
|
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncSession
|
|
@@ -10,23 +12,54 @@ from sqlalchemy.ext.asyncio import AsyncConnection, AsyncSession
|
|
|
10
12
|
KeyT = TypeVar("KeyT")
|
|
11
13
|
|
|
12
14
|
|
|
13
|
-
class
|
|
14
|
-
def
|
|
15
|
-
|
|
15
|
+
class LocksetLock(Protocol):
|
|
16
|
+
async def acquire(self) -> bool: ...
|
|
17
|
+
def release(self) -> None: ...
|
|
18
|
+
async def __aenter__(self): ...
|
|
19
|
+
async def __aexit__(self, exc_type, exc, tb): ...
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
T = TypeVar("T")
|
|
23
|
+
|
|
16
24
|
|
|
17
|
-
|
|
25
|
+
class Lockset(Protocol[T]):
|
|
26
|
+
def __contains__(self, item: T) -> bool: ...
|
|
27
|
+
def __iter__(self) -> Iterator[T]: ...
|
|
28
|
+
def __len__(self) -> int: ...
|
|
29
|
+
def add(self, item: T) -> None: ...
|
|
30
|
+
def discard(self, item: T) -> None: ...
|
|
31
|
+
def update(self, other: Iterable[T]) -> None: ...
|
|
32
|
+
def difference_update(self, other: Iterable[T]) -> None: ...
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ResourceLocker:
|
|
36
|
+
@abstractmethod
|
|
37
|
+
def get_lockset(self, namespace: str) -> tuple[LocksetLock, Lockset]:
|
|
18
38
|
"""
|
|
19
39
|
Returns a lockset containing locked resources for in-memory locking.
|
|
20
40
|
Also returns a lock that guards the lockset.
|
|
21
41
|
"""
|
|
22
|
-
|
|
42
|
+
pass
|
|
23
43
|
|
|
44
|
+
@abstractmethod
|
|
24
45
|
@asynccontextmanager
|
|
25
|
-
async def lock_ctx(self, namespace: str, keys:
|
|
46
|
+
async def lock_ctx(self, namespace: str, keys: list[KeyT]):
|
|
26
47
|
"""
|
|
27
48
|
Acquires locks for all keys in namespace.
|
|
28
49
|
The keys must be sorted to prevent deadlock.
|
|
29
50
|
"""
|
|
51
|
+
yield
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class InMemoryResourceLocker(ResourceLocker):
|
|
55
|
+
def __init__(self):
|
|
56
|
+
self.namespace_to_locks_map: dict[str, tuple[Lock, set]] = {}
|
|
57
|
+
|
|
58
|
+
def get_lockset(self, namespace: str) -> tuple[Lock, set]:
|
|
59
|
+
return self.namespace_to_locks_map.setdefault(namespace, (Lock(), set()))
|
|
60
|
+
|
|
61
|
+
@asynccontextmanager
|
|
62
|
+
async def lock_ctx(self, namespace: str, keys: list[KeyT]):
|
|
30
63
|
lock, lockset = self.get_lockset(namespace)
|
|
31
64
|
try:
|
|
32
65
|
await _wait_to_lock_many(lock, lockset, keys)
|
|
@@ -35,6 +68,56 @@ class ResourceLocker:
|
|
|
35
68
|
lockset.difference_update(keys)
|
|
36
69
|
|
|
37
70
|
|
|
71
|
+
class DummyAsyncLock:
|
|
72
|
+
async def __aenter__(self):
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
async def __aexit__(self, exc_type, exc, tb):
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
async def acquire(self):
|
|
79
|
+
return True
|
|
80
|
+
|
|
81
|
+
def release(self):
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class DummySet(collections.abc.MutableSet):
|
|
86
|
+
def __contains__(self, item):
|
|
87
|
+
return False
|
|
88
|
+
|
|
89
|
+
def __iter__(self):
|
|
90
|
+
return iter(())
|
|
91
|
+
|
|
92
|
+
def __len__(self):
|
|
93
|
+
return 0
|
|
94
|
+
|
|
95
|
+
def add(self, value):
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
def discard(self, value):
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
def update(self, other):
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
def difference_update(self, other):
|
|
105
|
+
pass
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class DummyResourceLocker(ResourceLocker):
|
|
109
|
+
def __init__(self):
|
|
110
|
+
self.lock = DummyAsyncLock()
|
|
111
|
+
self.lockset = DummySet()
|
|
112
|
+
|
|
113
|
+
def get_lockset(self, namespace: str) -> tuple[DummyAsyncLock, DummySet]:
|
|
114
|
+
return self.lock, self.lockset
|
|
115
|
+
|
|
116
|
+
@asynccontextmanager
|
|
117
|
+
async def lock_ctx(self, namespace: str, keys: list[KeyT]):
|
|
118
|
+
yield
|
|
119
|
+
|
|
120
|
+
|
|
38
121
|
def string_to_lock_id(s: str) -> int:
|
|
39
122
|
return int(hashlib.sha256(s.encode()).hexdigest(), 16) % (2**63)
|
|
40
123
|
|
|
@@ -67,15 +150,21 @@ async def try_advisory_lock_ctx(
|
|
|
67
150
|
await bind.execute(select(func.pg_advisory_unlock(string_to_lock_id(resource))))
|
|
68
151
|
|
|
69
152
|
|
|
70
|
-
|
|
153
|
+
_in_memory_locker = InMemoryResourceLocker()
|
|
154
|
+
_dummy_locker = DummyResourceLocker()
|
|
71
155
|
|
|
72
156
|
|
|
73
|
-
def get_locker() -> ResourceLocker:
|
|
74
|
-
|
|
157
|
+
def get_locker(dialect_name: str) -> ResourceLocker:
|
|
158
|
+
if dialect_name == "sqlite":
|
|
159
|
+
return _in_memory_locker
|
|
160
|
+
# We could use an in-memory locker on Postgres
|
|
161
|
+
# but it can lead to unnecessary lock contention,
|
|
162
|
+
# so we use a dummy locker that does not take any locks.
|
|
163
|
+
return _dummy_locker
|
|
75
164
|
|
|
76
165
|
|
|
77
166
|
async def _wait_to_lock_many(
|
|
78
|
-
lock: asyncio.Lock, locked:
|
|
167
|
+
lock: asyncio.Lock, locked: set[KeyT], keys: list[KeyT], *, delay: float = 0.1
|
|
79
168
|
):
|
|
80
169
|
"""
|
|
81
170
|
Retry locking until all the keys are locked.
|
|
@@ -88,7 +177,7 @@ async def _wait_to_lock_many(
|
|
|
88
177
|
locked_now_num = 0
|
|
89
178
|
for key in left_to_lock:
|
|
90
179
|
if key in locked:
|
|
91
|
-
# Someone already
|
|
180
|
+
# Someone already acquired the lock, wait
|
|
92
181
|
break
|
|
93
182
|
locked.add(key)
|
|
94
183
|
locked_now_num += 1
|
|
@@ -482,8 +482,9 @@ async def submit_run(
|
|
|
482
482
|
select(func.pg_advisory_xact_lock(string_to_lock_id(lock_namespace)))
|
|
483
483
|
)
|
|
484
484
|
|
|
485
|
-
lock, _ = get_locker().get_lockset(lock_namespace)
|
|
485
|
+
lock, _ = get_locker(get_db().dialect_name).get_lockset(lock_namespace)
|
|
486
486
|
async with lock:
|
|
487
|
+
# FIXME: delete_runs commits, so Postgres lock is released too early.
|
|
487
488
|
if run_spec.run_name is None:
|
|
488
489
|
run_spec.run_name = await _generate_run_name(
|
|
489
490
|
session=session,
|
|
@@ -586,46 +587,29 @@ async def stop_runs(
|
|
|
586
587
|
)
|
|
587
588
|
run_models = res.scalars().all()
|
|
588
589
|
run_ids = sorted([r.id for r in run_models])
|
|
589
|
-
res = await session.execute(select(JobModel).where(JobModel.run_id.in_(run_ids)))
|
|
590
|
-
job_models = res.scalars().all()
|
|
591
|
-
job_ids = sorted([j.id for j in job_models])
|
|
592
590
|
await session.commit()
|
|
593
|
-
async with (
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
591
|
+
async with get_locker(get_db().dialect_name).lock_ctx(RunModel.__tablename__, run_ids):
|
|
592
|
+
res = await session.execute(
|
|
593
|
+
select(RunModel)
|
|
594
|
+
.where(RunModel.id.in_(run_ids))
|
|
595
|
+
.order_by(RunModel.id) # take locks in order
|
|
596
|
+
.with_for_update(key_share=True)
|
|
597
|
+
.execution_options(populate_existing=True)
|
|
598
|
+
)
|
|
599
|
+
run_models = res.scalars().all()
|
|
600
|
+
now = common_utils.get_current_datetime()
|
|
597
601
|
for run_model in run_models:
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
run_model = res.scalar_one()
|
|
610
|
-
await session.execute(
|
|
611
|
-
select(JobModel)
|
|
612
|
-
.where(JobModel.run_id == run_model.id)
|
|
613
|
-
.order_by(JobModel.id) # take locks in order
|
|
614
|
-
.with_for_update(key_share=True)
|
|
615
|
-
.execution_options(populate_existing=True)
|
|
616
|
-
)
|
|
617
|
-
if run_model.status.is_finished():
|
|
618
|
-
return
|
|
619
|
-
run_model.status = RunStatus.TERMINATING
|
|
620
|
-
if abort:
|
|
621
|
-
run_model.termination_reason = RunTerminationReason.ABORTED_BY_USER
|
|
622
|
-
else:
|
|
623
|
-
run_model.termination_reason = RunTerminationReason.STOPPED_BY_USER
|
|
624
|
-
# process the run out of turn
|
|
625
|
-
logger.debug("%s: terminating because %s", fmt(run_model), run_model.termination_reason.name)
|
|
626
|
-
await process_terminating_run(session, run_model)
|
|
627
|
-
run_model.last_processed_at = common_utils.get_current_datetime()
|
|
628
|
-
await session.commit()
|
|
602
|
+
if run_model.status.is_finished():
|
|
603
|
+
continue
|
|
604
|
+
run_model.status = RunStatus.TERMINATING
|
|
605
|
+
if abort:
|
|
606
|
+
run_model.termination_reason = RunTerminationReason.ABORTED_BY_USER
|
|
607
|
+
else:
|
|
608
|
+
run_model.termination_reason = RunTerminationReason.STOPPED_BY_USER
|
|
609
|
+
run_model.last_processed_at = now
|
|
610
|
+
# The run will be terminated by process_runs.
|
|
611
|
+
# Terminating synchronously is problematic since it may take a long time.
|
|
612
|
+
await session.commit()
|
|
629
613
|
|
|
630
614
|
|
|
631
615
|
async def delete_runs(
|
|
@@ -642,7 +626,7 @@ async def delete_runs(
|
|
|
642
626
|
run_models = res.scalars().all()
|
|
643
627
|
run_ids = sorted([r.id for r in run_models])
|
|
644
628
|
await session.commit()
|
|
645
|
-
async with get_locker().lock_ctx(RunModel.__tablename__, run_ids):
|
|
629
|
+
async with get_locker(get_db().dialect_name).lock_ctx(RunModel.__tablename__, run_ids):
|
|
646
630
|
res = await session.execute(
|
|
647
631
|
select(RunModel)
|
|
648
632
|
.where(RunModel.id.in_(run_ids))
|
|
@@ -223,7 +223,7 @@ async def create_volume(
|
|
|
223
223
|
select(func.pg_advisory_xact_lock(string_to_lock_id(lock_namespace)))
|
|
224
224
|
)
|
|
225
225
|
|
|
226
|
-
lock, _ = get_locker().get_lockset(lock_namespace)
|
|
226
|
+
lock, _ = get_locker(get_db().dialect_name).get_lockset(lock_namespace)
|
|
227
227
|
async with lock:
|
|
228
228
|
if configuration.name is not None:
|
|
229
229
|
volume_model = await get_project_volume_model_by_name(
|
|
@@ -262,7 +262,7 @@ async def delete_volumes(session: AsyncSession, project: ProjectModel, names: Li
|
|
|
262
262
|
volumes_ids = sorted([v.id for v in volume_models])
|
|
263
263
|
await session.commit()
|
|
264
264
|
logger.info("Deleting volumes: %s", [v.name for v in volume_models])
|
|
265
|
-
async with get_locker().lock_ctx(VolumeModel.__tablename__, volumes_ids):
|
|
265
|
+
async with get_locker(get_db().dialect_name).lock_ctx(VolumeModel.__tablename__, volumes_ids):
|
|
266
266
|
# Refetch after lock
|
|
267
267
|
res = await session.execute(
|
|
268
268
|
select(VolumeModel)
|
|
@@ -27,10 +27,22 @@ LOG_FORMAT = os.getenv("DSTACK_SERVER_LOG_FORMAT", "rich").lower()
|
|
|
27
27
|
ALEMBIC_MIGRATIONS_LOCATION = os.getenv(
|
|
28
28
|
"DSTACK_ALEMBIC_MIGRATIONS_LOCATION", "dstack._internal.server:migrations"
|
|
29
29
|
)
|
|
30
|
-
|
|
31
|
-
#
|
|
32
|
-
|
|
33
|
-
|
|
30
|
+
|
|
31
|
+
# Users may want to increase client pool size to support more concurrent resources
|
|
32
|
+
# if their db supports many connections.
|
|
33
|
+
DB_POOL_SIZE = int(os.getenv("DSTACK_DB_POOL_SIZE", 20))
|
|
34
|
+
DB_MAX_OVERFLOW = int(os.getenv("DSTACK_DB_MAX_OVERFLOW", 20))
|
|
35
|
+
|
|
36
|
+
# Scale the number of background processing tasks
|
|
37
|
+
# allowing to process more resources on one server replica.
|
|
38
|
+
# Not recommended to change on SQLite.
|
|
39
|
+
# DSTACK_DB_POOL_SIZE and DSTACK_DB_MAX_OVERFLOW
|
|
40
|
+
# must be increased proportionally.
|
|
41
|
+
SERVER_BACKGROUND_PROCESSING_FACTOR = int(
|
|
42
|
+
os.getenv("DSTACK_SERVER_BACKGROUND_PROCESSING_FACTOR", 1)
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
SERVER_EXECUTOR_MAX_WORKERS = int(os.getenv("DSTACK_SERVER_EXECUTOR_MAX_WORKERS", 128))
|
|
34
46
|
|
|
35
47
|
MAX_OFFERS_TRIED = int(os.getenv("DSTACK_SERVER_MAX_OFFERS_TRIED", 25))
|
|
36
48
|
|
|
@@ -97,6 +109,8 @@ SERVER_CODE_UPLOAD_LIMIT = int(os.getenv("DSTACK_SERVER_CODE_UPLOAD_LIMIT", 2 *
|
|
|
97
109
|
|
|
98
110
|
SQL_ECHO_ENABLED = os.getenv("DSTACK_SQL_ECHO_ENABLED") is not None
|
|
99
111
|
|
|
112
|
+
SERVER_PROFILING_ENABLED = os.getenv("DSTACK_SERVER_PROFILING_ENABLED") is not None
|
|
113
|
+
|
|
100
114
|
UPDATE_DEFAULT_PROJECT = os.getenv("DSTACK_UPDATE_DEFAULT_PROJECT") is not None
|
|
101
115
|
DO_NOT_UPDATE_DEFAULT_PROJECT = os.getenv("DSTACK_DO_NOT_UPDATE_DEFAULT_PROJECT") is not None
|
|
102
116
|
SKIP_GATEWAY_UPDATE = os.getenv("DSTACK_SKIP_GATEWAY_UPDATE", None) is not None
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
<!doctype html><html lang="en"><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><title>dstack</title><meta name="description" content="Get GPUs at the best prices and availability from a wide range of providers. No cloud account of your own is required.
|
|
2
2
|
"/><link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin><link href="https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap" rel="stylesheet"><meta name="og:title" content="dstack"><meta name="og:type" content="article"><meta name="og:image" content="/splash_thumbnail.png"><meta name="og:description" content="Get GPUs at the best prices and availability from a wide range of providers. No cloud account of your own is required.
|
|
3
|
-
"><link rel="icon" type="image/x-icon" href="/assets/favicon.ico"><link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon-16x16.png"><link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon-32x32.png"><link rel="icon" type="image/png" sizes="48x48" href="/assets/favicon-48x48.png"><link rel="manifest" href="/assets/manifest.webmanifest"><meta name="mobile-web-app-capable" content="yes"><meta name="theme-color" content="#fff"><meta name="application-name" content="dstackai"><link rel="apple-touch-icon" sizes="57x57" href="/assets/apple-touch-icon-57x57.png"><link rel="apple-touch-icon" sizes="60x60" href="/assets/apple-touch-icon-60x60.png"><link rel="apple-touch-icon" sizes="72x72" href="/assets/apple-touch-icon-72x72.png"><link rel="apple-touch-icon" sizes="76x76" href="/assets/apple-touch-icon-76x76.png"><link rel="apple-touch-icon" sizes="114x114" href="/assets/apple-touch-icon-114x114.png"><link rel="apple-touch-icon" sizes="120x120" href="/assets/apple-touch-icon-120x120.png"><link rel="apple-touch-icon" sizes="144x144" href="/assets/apple-touch-icon-144x144.png"><link rel="apple-touch-icon" sizes="152x152" href="/assets/apple-touch-icon-152x152.png"><link rel="apple-touch-icon" sizes="167x167" href="/assets/apple-touch-icon-167x167.png"><link rel="apple-touch-icon" sizes="180x180" href="/assets/apple-touch-icon-180x180.png"><link rel="apple-touch-icon" sizes="1024x1024" href="/assets/apple-touch-icon-1024x1024.png"><meta name="apple-mobile-web-app-capable" content="yes"><meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"><meta name="apple-mobile-web-app-title" content="dstackai"><link rel="apple-touch-startup-image" media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-640x1136.png"><link rel="apple-touch-startup-image" media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-1136x640.png"><link rel="apple-touch-startup-image" media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-750x1334.png"><link rel="apple-touch-startup-image" media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-1334x750.png"><link rel="apple-touch-startup-image" media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1125x2436.png"><link rel="apple-touch-startup-image" media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2436x1125.png"><link rel="apple-touch-startup-image" media="(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1170x2532.png"><link rel="apple-touch-startup-image" media="(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2532x1170.png"><link rel="apple-touch-startup-image" media="(device-width: 393px) and (device-height: 852px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1179x2556.png"><link rel="apple-touch-startup-image" media="(device-width: 393px) and (device-height: 852px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2556x1179.png"><link rel="apple-touch-startup-image" media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-828x1792.png"><link rel="apple-touch-startup-image" media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-1792x828.png"><link rel="apple-touch-startup-image" media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1242x2688.png"><link rel="apple-touch-startup-image" media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2688x1242.png"><link rel="apple-touch-startup-image" media="(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1242x2208.png"><link rel="apple-touch-startup-image" media="(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2208x1242.png"><link rel="apple-touch-startup-image" media="(device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1284x2778.png"><link rel="apple-touch-startup-image" media="(device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2778x1284.png"><link rel="apple-touch-startup-image" media="(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1290x2796.png"><link rel="apple-touch-startup-image" media="(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2796x1290.png"><link rel="apple-touch-startup-image" media="(device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1488x2266.png"><link rel="apple-touch-startup-image" media="(device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2266x1488.png"><link rel="apple-touch-startup-image" media="(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1536x2048.png"><link rel="apple-touch-startup-image" media="(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2048x1536.png"><link rel="apple-touch-startup-image" media="(device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1620x2160.png"><link rel="apple-touch-startup-image" media="(device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2160x1620.png"><link rel="apple-touch-startup-image" media="(device-width: 820px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1640x2160.png"><link rel="apple-touch-startup-image" media="(device-width: 820px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2160x1640.png"><link rel="apple-touch-startup-image" media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1668x2388.png"><link rel="apple-touch-startup-image" media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2388x1668.png"><link rel="apple-touch-startup-image" media="(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1668x2224.png"><link rel="apple-touch-startup-image" media="(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2224x1668.png"><link rel="apple-touch-startup-image" media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-2048x2732.png"><link rel="apple-touch-startup-image" media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2732x2048.png"><meta name="msapplication-TileColor" content="#fff"><meta name="msapplication-TileImage" content="/assets/mstile-144x144.png"><meta name="msapplication-config" content="/assets/browserconfig.xml"><link rel="yandex-tableau-widget" href="/assets/yandex-browser-manifest.json"><script defer="defer" src="/main-
|
|
3
|
+
"><link rel="icon" type="image/x-icon" href="/assets/favicon.ico"><link rel="icon" type="image/png" sizes="16x16" href="/assets/favicon-16x16.png"><link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon-32x32.png"><link rel="icon" type="image/png" sizes="48x48" href="/assets/favicon-48x48.png"><link rel="manifest" href="/assets/manifest.webmanifest"><meta name="mobile-web-app-capable" content="yes"><meta name="theme-color" content="#fff"><meta name="application-name" content="dstackai"><link rel="apple-touch-icon" sizes="57x57" href="/assets/apple-touch-icon-57x57.png"><link rel="apple-touch-icon" sizes="60x60" href="/assets/apple-touch-icon-60x60.png"><link rel="apple-touch-icon" sizes="72x72" href="/assets/apple-touch-icon-72x72.png"><link rel="apple-touch-icon" sizes="76x76" href="/assets/apple-touch-icon-76x76.png"><link rel="apple-touch-icon" sizes="114x114" href="/assets/apple-touch-icon-114x114.png"><link rel="apple-touch-icon" sizes="120x120" href="/assets/apple-touch-icon-120x120.png"><link rel="apple-touch-icon" sizes="144x144" href="/assets/apple-touch-icon-144x144.png"><link rel="apple-touch-icon" sizes="152x152" href="/assets/apple-touch-icon-152x152.png"><link rel="apple-touch-icon" sizes="167x167" href="/assets/apple-touch-icon-167x167.png"><link rel="apple-touch-icon" sizes="180x180" href="/assets/apple-touch-icon-180x180.png"><link rel="apple-touch-icon" sizes="1024x1024" href="/assets/apple-touch-icon-1024x1024.png"><meta name="apple-mobile-web-app-capable" content="yes"><meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"><meta name="apple-mobile-web-app-title" content="dstackai"><link rel="apple-touch-startup-image" media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-640x1136.png"><link rel="apple-touch-startup-image" media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-1136x640.png"><link rel="apple-touch-startup-image" media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-750x1334.png"><link rel="apple-touch-startup-image" media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-1334x750.png"><link rel="apple-touch-startup-image" media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1125x2436.png"><link rel="apple-touch-startup-image" media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2436x1125.png"><link rel="apple-touch-startup-image" media="(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1170x2532.png"><link rel="apple-touch-startup-image" media="(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2532x1170.png"><link rel="apple-touch-startup-image" media="(device-width: 393px) and (device-height: 852px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1179x2556.png"><link rel="apple-touch-startup-image" media="(device-width: 393px) and (device-height: 852px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2556x1179.png"><link rel="apple-touch-startup-image" media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-828x1792.png"><link rel="apple-touch-startup-image" media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-1792x828.png"><link rel="apple-touch-startup-image" media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1242x2688.png"><link rel="apple-touch-startup-image" media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2688x1242.png"><link rel="apple-touch-startup-image" media="(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1242x2208.png"><link rel="apple-touch-startup-image" media="(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2208x1242.png"><link rel="apple-touch-startup-image" media="(device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1284x2778.png"><link rel="apple-touch-startup-image" media="(device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2778x1284.png"><link rel="apple-touch-startup-image" media="(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1290x2796.png"><link rel="apple-touch-startup-image" media="(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2796x1290.png"><link rel="apple-touch-startup-image" media="(device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1488x2266.png"><link rel="apple-touch-startup-image" media="(device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2266x1488.png"><link rel="apple-touch-startup-image" media="(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1536x2048.png"><link rel="apple-touch-startup-image" media="(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2048x1536.png"><link rel="apple-touch-startup-image" media="(device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1620x2160.png"><link rel="apple-touch-startup-image" media="(device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2160x1620.png"><link rel="apple-touch-startup-image" media="(device-width: 820px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1640x2160.png"><link rel="apple-touch-startup-image" media="(device-width: 820px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2160x1640.png"><link rel="apple-touch-startup-image" media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1668x2388.png"><link rel="apple-touch-startup-image" media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2388x1668.png"><link rel="apple-touch-startup-image" media="(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-1668x2224.png"><link rel="apple-touch-startup-image" media="(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2224x1668.png"><link rel="apple-touch-startup-image" media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="/assets/apple-touch-startup-image-2048x2732.png"><link rel="apple-touch-startup-image" media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)" href="/assets/apple-touch-startup-image-2732x2048.png"><meta name="msapplication-TileColor" content="#fff"><meta name="msapplication-TileImage" content="/assets/mstile-144x144.png"><meta name="msapplication-config" content="/assets/browserconfig.xml"><link rel="yandex-tableau-widget" href="/assets/yandex-browser-manifest.json"><script defer="defer" src="/main-d1ac2e8c38ed5f08a114.js"></script><link href="/main-d58fc0460cb0eae7cb5c.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div class="b-page-header" id="header"></div><div id="root"></div></body></html>
|