Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/dstack/_internal/server/services/runs/plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -832,7 +832,7 @@ def _get_job_plan(
job_offers.extend(offer for _, offer in instance_offers)
if profile.creation_policy == CreationPolicy.REUSE_OR_CREATE:
job_offers.extend(offer for _, offer in backend_offers)
job_offers.sort(key=lambda offer: not offer.availability.is_available())
job_offers.sort(key=lambda offer: (not offer.availability.is_available(), offer.price))
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I'm not mistaken, sorting here:

  • Only affects the run plan shown in dstack apply, but doesn't affect the actual provisioning order.
  • Can shuffle the available instance_offers (representing idle instances) and backend_offers (representing new instances to be provisioned), which is not expected — idle instances should always come first regardless of the price, as dstack prefers reusing them over provisioning new ones.

Anyways, I'm not sure if we want to sort by price in the first place, please see my issue comment

remove_job_spec_sensitive_info(job.job_spec)
return JobPlan(
job_spec=job.job_spec,
Expand Down
158 changes: 157 additions & 1 deletion src/tests/_internal/server/services/runs/test_plan.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
import copy
from unittest.mock import AsyncMock
from unittest.mock import AsyncMock, Mock

import pytest
from sqlalchemy.ext.asyncio import AsyncSession

from dstack._internal.core.models.backends.base import BackendType
from dstack._internal.core.models.configurations import TaskConfiguration
from dstack._internal.core.models.fleets import FleetNodesSpec, InstanceGroupPlacement
from dstack._internal.core.models.instances import InstanceAvailability
from dstack._internal.core.models.profiles import CreationPolicy, Profile
from dstack._internal.core.models.resources import ResourcesSpec
from dstack._internal.core.models.runs import Job, JobSpec, Requirements
from dstack._internal.server.services.jobs import get_jobs_from_run_spec
from dstack._internal.server.services.runs.plan import (
_freeze_offer_identity_value,
_get_backend_offer_identity,
_get_backend_offers_in_fleet,
_get_job_plan,
)
from dstack._internal.server.testing.common import (
create_fleet,
Expand Down Expand Up @@ -113,3 +118,154 @@ async def test_keeps_unconstrained_offers_for_non_empty_cluster_fleet_without_el
get_offers_by_requirements_mock.await_args.kwargs["master_job_provisioning_data"]
is None
)


class TestGetJobPlan:
def _make_job(self):
job_spec = JobSpec(
job_num=0,
job_name="test-job",
commands=[":"],
env={},
home_dir="/root",
image_name="scratch",
max_duration=None,
registry_auth=None,
requirements=Requirements(resources=ResourcesSpec()),
retry=None,
working_dir=None,
)
return Job(job_spec=job_spec, job_submissions=[])

def test_sorts_available_offers_by_price(self) -> None:
cheap_available = get_instance_offer_with_availability(
backend=BackendType.VASTAI, price=0.37, availability=InstanceAvailability.AVAILABLE
)
mid_available = get_instance_offer_with_availability(
backend=BackendType.RUNPOD, price=0.98, availability=InstanceAvailability.AVAILABLE
)
expensive_available = get_instance_offer_with_availability(
backend=BackendType.RUNPOD, price=1.49, availability=InstanceAvailability.AVAILABLE
)
profile = Profile(name="test", creation_policy=CreationPolicy.REUSE_OR_CREATE)
job = self._make_job()

plan = _get_job_plan(
instance_offers=[],
backend_offers=[
(Mock(), expensive_available),
(Mock(), cheap_available),
(Mock(), mid_available),
],
profile=profile,
job=job,
max_offers=None,
)

assert [o.price for o in plan.offers] == [0.37, 0.98, 1.49]

def test_sorts_not_available_offers_by_price(self) -> None:
cheap_na = get_instance_offer_with_availability(
price=0.37, availability=InstanceAvailability.NOT_AVAILABLE
)
expensive_na = get_instance_offer_with_availability(
price=1.49, availability=InstanceAvailability.NOT_AVAILABLE
)
profile = Profile(name="test", creation_policy=CreationPolicy.REUSE_OR_CREATE)
job = self._make_job()

plan = _get_job_plan(
instance_offers=[],
backend_offers=[
(Mock(), expensive_na),
(Mock(), cheap_na),
],
profile=profile,
job=job,
max_offers=None,
)

assert [o.price for o in plan.offers] == [0.37, 1.49]

def test_sorts_mixed_availability_by_availability_then_price(self) -> None:
available_expensive = get_instance_offer_with_availability(
price=1.49, availability=InstanceAvailability.AVAILABLE
)
available_cheap = get_instance_offer_with_availability(
price=0.98, availability=InstanceAvailability.AVAILABLE
)
na_expensive = get_instance_offer_with_availability(
price=2.00, availability=InstanceAvailability.NOT_AVAILABLE
)
na_cheap = get_instance_offer_with_availability(
price=0.37, availability=InstanceAvailability.NOT_AVAILABLE
)
profile = Profile(name="test", creation_policy=CreationPolicy.REUSE_OR_CREATE)
job = self._make_job()

plan = _get_job_plan(
instance_offers=[],
backend_offers=[
(Mock(), available_expensive),
(Mock(), na_expensive),
(Mock(), available_cheap),
(Mock(), na_cheap),
],
profile=profile,
job=job,
max_offers=None,
)

assert [o.price for o in plan.offers] == [0.98, 1.49, 0.37, 2.00]

def test_sorts_unsorted_multi_backend_offers_by_price(self) -> None:
runpod_expensive = get_instance_offer_with_availability(
backend=BackendType.RUNPOD, price=0.98, availability=InstanceAvailability.AVAILABLE
)
runpod_mid = get_instance_offer_with_availability(
backend=BackendType.RUNPOD, price=1.49, availability=InstanceAvailability.AVAILABLE
)
vastai_cheap = get_instance_offer_with_availability(
backend=BackendType.VASTAI, price=0.37, availability=InstanceAvailability.AVAILABLE
)
vastai_mid = get_instance_offer_with_availability(
backend=BackendType.VASTAI, price=1.22, availability=InstanceAvailability.AVAILABLE
)
profile = Profile(name="test", creation_policy=CreationPolicy.REUSE_OR_CREATE)
job = self._make_job()

plan = _get_job_plan(
instance_offers=[],
backend_offers=[
(Mock(), runpod_expensive),
(Mock(), runpod_mid),
(Mock(), vastai_cheap),
(Mock(), vastai_mid),
],
profile=profile,
job=job,
max_offers=None,
)

assert [o.price for o in plan.offers] == [0.37, 0.98, 1.22, 1.49]

def test_instance_offers_and_backend_offers_sorted_by_availability_then_price(self) -> None:
idle_expensive = get_instance_offer_with_availability(
price=0.98, availability=InstanceAvailability.IDLE
)
backend_cheap = get_instance_offer_with_availability(
price=0.37, availability=InstanceAvailability.AVAILABLE
)
profile = Profile(name="test", creation_policy=CreationPolicy.REUSE_OR_CREATE)
job = self._make_job()
instance_model = Mock()

plan = _get_job_plan(
instance_offers=[(instance_model, idle_expensive)],
backend_offers=[(Mock(), backend_cheap)],
profile=profile,
job=job,
max_offers=None,
)

assert [o.price for o in plan.offers] == [0.37, 0.98]
Loading