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.

Files changed (43) hide show
  1. dstack/_internal/cli/services/configurators/fleet.py +13 -1
  2. dstack/_internal/core/backends/aws/compute.py +237 -18
  3. dstack/_internal/core/backends/base/compute.py +20 -2
  4. dstack/_internal/core/backends/cudo/compute.py +23 -9
  5. dstack/_internal/core/backends/gcp/compute.py +13 -7
  6. dstack/_internal/core/backends/lambdalabs/compute.py +2 -1
  7. dstack/_internal/core/compatibility/fleets.py +12 -11
  8. dstack/_internal/core/compatibility/gateways.py +9 -8
  9. dstack/_internal/core/compatibility/logs.py +4 -3
  10. dstack/_internal/core/compatibility/runs.py +17 -20
  11. dstack/_internal/core/compatibility/volumes.py +9 -8
  12. dstack/_internal/core/errors.py +4 -0
  13. dstack/_internal/core/models/common.py +7 -0
  14. dstack/_internal/core/services/diff.py +36 -3
  15. dstack/_internal/server/app.py +20 -0
  16. dstack/_internal/server/background/__init__.py +61 -37
  17. dstack/_internal/server/background/tasks/process_fleets.py +19 -3
  18. dstack/_internal/server/background/tasks/process_gateways.py +1 -1
  19. dstack/_internal/server/background/tasks/process_instances.py +13 -2
  20. dstack/_internal/server/background/tasks/process_placement_groups.py +4 -2
  21. dstack/_internal/server/background/tasks/process_running_jobs.py +14 -3
  22. dstack/_internal/server/background/tasks/process_runs.py +8 -4
  23. dstack/_internal/server/background/tasks/process_submitted_jobs.py +36 -7
  24. dstack/_internal/server/background/tasks/process_terminating_jobs.py +5 -3
  25. dstack/_internal/server/background/tasks/process_volumes.py +2 -2
  26. dstack/_internal/server/services/fleets.py +5 -4
  27. dstack/_internal/server/services/gateways/__init__.py +4 -2
  28. dstack/_internal/server/services/jobs/configurators/base.py +5 -1
  29. dstack/_internal/server/services/locking.py +101 -12
  30. dstack/_internal/server/services/runs.py +24 -40
  31. dstack/_internal/server/services/volumes.py +2 -2
  32. dstack/_internal/server/settings.py +18 -4
  33. dstack/_internal/server/statics/index.html +1 -1
  34. dstack/_internal/server/statics/{main-d151637af20f70b2e796.js → main-d1ac2e8c38ed5f08a114.js} +68 -64
  35. dstack/_internal/server/statics/{main-d151637af20f70b2e796.js.map → main-d1ac2e8c38ed5f08a114.js.map} +1 -1
  36. dstack/_internal/server/statics/{main-d48635d8fe670d53961c.css → main-d58fc0460cb0eae7cb5c.css} +1 -1
  37. dstack/_internal/server/testing/common.py +7 -3
  38. dstack/version.py +1 -1
  39. {dstack-0.19.17.dist-info → dstack-0.19.18.dist-info}/METADATA +11 -10
  40. {dstack-0.19.17.dist-info → dstack-0.19.18.dist-info}/RECORD +43 -43
  41. {dstack-0.19.17.dist-info → dstack-0.19.18.dist-info}/WHEEL +0 -0
  42. {dstack-0.19.17.dist-info → dstack-0.19.18.dist-info}/entry_points.txt +0 -0
  43. {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 submision to provisioning.",
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
- for _ in range(batch_size):
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(InstanceModel.__tablename__, instances_ids):
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
- await session.commit()
247
- return
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(FleetModel.__tablename__, [fleet_model.id]):
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(InstanceModel.__tablename__)
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 lock instances with FOR UPDATE?
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(GatewayModel.__tablename__, gateways_ids):
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(TTLCache(maxsize=2048, ttl=80))
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, Dict, List, Set, Tuple, TypeVar, Union
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 ResourceLocker:
14
- def __init__(self):
15
- self.namespace_to_locks_map: Dict[str, Tuple[Lock, set]] = {}
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
- def get_lockset(self, namespace: str) -> Tuple[Lock, set]:
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
- return self.namespace_to_locks_map.setdefault(namespace, (Lock(), set()))
42
+ pass
23
43
 
44
+ @abstractmethod
24
45
  @asynccontextmanager
25
- async def lock_ctx(self, namespace: str, keys: List[KeyT]):
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
- _locker = ResourceLocker()
153
+ _in_memory_locker = InMemoryResourceLocker()
154
+ _dummy_locker = DummyResourceLocker()
71
155
 
72
156
 
73
- def get_locker() -> ResourceLocker:
74
- return _locker
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: Set[KeyT], keys: List[KeyT], *, delay: float = 0.1
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 aquired the lock, wait
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
- get_locker().lock_ctx(RunModel.__tablename__, run_ids),
595
- get_locker().lock_ctx(JobModel.__tablename__, job_ids),
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
- await stop_run(session=session, run_model=run_model, abort=abort)
599
-
600
-
601
- async def stop_run(session: AsyncSession, run_model: RunModel, abort: bool):
602
- res = await session.execute(
603
- select(RunModel)
604
- .where(RunModel.id == run_model.id)
605
- .order_by(RunModel.id) # take locks in order
606
- .with_for_update(key_share=True)
607
- .execution_options(populate_existing=True)
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
- # Users may want to increase pool size to support more concurrent resources
31
- # if their db supports many connections
32
- DB_POOL_SIZE = int(os.getenv("DSTACK_DB_POOL_SIZE", 10))
33
- DB_MAX_OVERFLOW = int(os.getenv("DSTACK_DB_MAX_OVERFLOW", 10))
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-d151637af20f70b2e796.js"></script><link href="/main-d48635d8fe670d53961c.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>
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>