Skip to content

Commit e27e71f

Browse files
authored
feat(FIR-10741): Defaulting to cheapest engine type (#125)
1 parent 19fe944 commit e27e71f

File tree

5 files changed

+194
-23
lines changed

5 files changed

+194
-23
lines changed

src/firebolt/service/engine.py

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from logging import getLogger
22
from typing import List, Optional, Union
33

4+
from firebolt.common.exception import FireboltError
45
from firebolt.common.urls import (
56
ACCOUNT_ENGINE_BY_NAME_URL,
67
ACCOUNT_ENGINE_URL,
@@ -107,7 +108,7 @@ def create(
107108
region: Union[str, Region, None] = None,
108109
engine_type: Union[str, EngineType] = EngineType.GENERAL_PURPOSE,
109110
scale: int = 2,
110-
spec: str = "i3.4xlarge",
111+
spec: Optional[str] = None,
111112
auto_stop: int = 20,
112113
warmup: Union[str, WarmupMethod] = WarmupMethod.PRELOAD_INDEXES,
113114
description: str = "",
@@ -121,7 +122,8 @@ def create(
121122
engine_type: The engine type. GENERAL_PURPOSE or DATA_ANALYTICS
122123
scale: The number of compute instances on the engine.
123124
The scale can be any int from 1 to 128.
124-
spec: The AWS EC2 instance type.
125+
spec: Firebolt instance type. If not set will default to
126+
the cheapest instance.
125127
auto_stop: The amount of time (in minutes)
126128
after which the engine automatically stops.
127129
warmup: The warmup method that should be used.
@@ -162,9 +164,19 @@ def create(
162164
),
163165
)
164166

165-
instance_type_key = self.resource_manager.instance_types.get_by_name(
166-
instance_type_name=spec
167-
).key
167+
if spec:
168+
instance_type_key = self.resource_manager.instance_types.get_by_name(
169+
instance_type_name=spec
170+
).key
171+
else:
172+
instance_type = (
173+
self.resource_manager.instance_types.cheapest_instance_in_region(region)
174+
)
175+
if not instance_type:
176+
raise FireboltError(
177+
f"No suitable default instances found in region {region}"
178+
)
179+
instance_type_key = instance_type.key
168180

169181
engine_revision = EngineRevision(
170182
specification=EngineRevisionSpecification(

src/firebolt/service/instance_type.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from firebolt.common.urls import INSTANCE_TYPES_URL
44
from firebolt.common.util import cached_property
55
from firebolt.model.instance_type import InstanceType, InstanceTypeKey
6+
from firebolt.model.region import Region
67
from firebolt.service.base import BaseService
78

89

@@ -41,6 +42,31 @@ def instance_types_by_name(self) -> Dict[InstanceTypeLookup, InstanceType]:
4142
for i in self.instance_types
4243
}
4344

45+
def cheapest_instance_in_region(self, region: Region) -> Optional[InstanceType]:
46+
# Get only awailable instances in region
47+
response = self.client.get(
48+
url=INSTANCE_TYPES_URL,
49+
params={"page.first": 5000, "filter.id_region_id_eq": region.key.region_id},
50+
)
51+
instance_types = [
52+
InstanceType.parse_obj(i["node"]) for i in response.json()["edges"]
53+
]
54+
# Filter out instances without storage
55+
instance_list = [
56+
i
57+
for i in instance_types
58+
if i.storage_size_bytes and i.storage_size_bytes != "0"
59+
]
60+
if not instance_list:
61+
return None
62+
cheapest = min(
63+
instance_list,
64+
key=lambda x: x.price_per_hour_cents
65+
if x.price_per_hour_cents
66+
else float("Inf"),
67+
)
68+
return cheapest
69+
4470
def get_by_key(self, instance_type_key: InstanceTypeKey) -> InstanceType:
4571
"""Get an instance type by key."""
4672

tests/unit/service/conftest.py

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from firebolt.model.database import Database, DatabaseKey
2323
from firebolt.model.engine import Engine, EngineKey, EngineSettings
2424
from firebolt.model.instance_type import InstanceType, InstanceTypeKey
25+
from firebolt.model.region import Region
2526
from tests.unit.util import list_to_paginated_response
2627

2728

@@ -55,6 +56,8 @@ def instance_type_1(provider, region_1) -> InstanceType:
5556
instance_type_id="instance_type_id_1",
5657
),
5758
name="i3.4xlarge",
59+
price_per_hour_cents=10,
60+
storage_size_bytes=0,
5861
)
5962

6063

@@ -67,12 +70,35 @@ def instance_type_2(provider, region_2) -> InstanceType:
6770
instance_type_id="instance_type_id_2",
6871
),
6972
name="i3.8xlarge",
73+
price_per_hour_cents=20,
74+
storage_size_bytes=500,
7075
)
7176

7277

7378
@pytest.fixture
74-
def mock_instance_types(instance_type_1, instance_type_2) -> List[InstanceType]:
75-
return [instance_type_1, instance_type_2]
79+
def instance_type_3(provider, region_2) -> InstanceType:
80+
return InstanceType(
81+
key=InstanceTypeKey(
82+
provider_id=provider.provider_id,
83+
region_id=region_2.key.region_id,
84+
instance_type_id="instance_type_id_2",
85+
),
86+
name="i3.8xlarge",
87+
price_per_hour_cents=30,
88+
storage_size_bytes=500,
89+
)
90+
91+
92+
@pytest.fixture
93+
def cheapest_instance(instance_type_2) -> InstanceType:
94+
return instance_type_2
95+
96+
97+
@pytest.fixture
98+
def mock_instance_types(
99+
instance_type_1, instance_type_2, instance_type_3
100+
) -> List[InstanceType]:
101+
return [instance_type_1, instance_type_2, instance_type_3]
76102

77103

78104
@pytest.fixture
@@ -130,11 +156,58 @@ def do_mock(
130156
return do_mock
131157

132158

159+
@pytest.fixture
160+
def instance_type_region_1_callback(
161+
instance_type_region_1_url: str, mock_instance_types
162+
) -> Callable:
163+
def do_mock(
164+
request: httpx.Request = None,
165+
**kwargs,
166+
) -> Response:
167+
assert request.url == instance_type_region_1_url
168+
return Response(
169+
status_code=httpx.codes.OK,
170+
json=list_to_paginated_response(mock_instance_types),
171+
)
172+
173+
return do_mock
174+
175+
176+
@pytest.fixture
177+
def instance_type_empty_callback() -> Callable:
178+
def do_mock(
179+
request: httpx.Request = None,
180+
**kwargs,
181+
) -> Response:
182+
return Response(
183+
status_code=httpx.codes.OK,
184+
json=list_to_paginated_response([]),
185+
)
186+
187+
return do_mock
188+
189+
133190
@pytest.fixture
134191
def instance_type_url(settings: Settings) -> str:
135192
return f"https://{settings.server}{INSTANCE_TYPES_URL}?page.first=5000"
136193

137194

195+
@pytest.fixture
196+
def instance_type_region_1_url(settings: Settings, region_1: Region) -> str:
197+
return (
198+
f"https://{settings.server}{INSTANCE_TYPES_URL}?page.first=5000&"
199+
f"filter.id_region_id_eq={region_1.key.region_id}"
200+
)
201+
202+
203+
@pytest.fixture
204+
def instance_type_region_2_url(settings: Settings, region_2: Region) -> str:
205+
return (
206+
f"https://{settings.server}{INSTANCE_TYPES_URL}?page.first=5000&"
207+
f"filter.id_region_id_eq={region_2.key.region_id}"
208+
)
209+
210+
138211
@pytest.fixture
139212
def engine_callback(engine_url: str, mock_engine) -> Callable:
140213
def do_mock(

tests/unit/service/test_engine.py

Lines changed: 57 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@
44
from pytest_httpx import HTTPXMock
55

66
from firebolt.common import Settings
7-
from firebolt.common.exception import NoAttachedDatabaseError
7+
from firebolt.common.exception import FireboltError, NoAttachedDatabaseError
88
from firebolt.model.engine import Engine
99
from firebolt.model.instance_type import InstanceType
10+
from firebolt.model.region import Region
1011
from firebolt.service.manager import ResourceManager
1112

1213

@@ -16,8 +17,8 @@ def test_engine_create(
1617
auth_url: str,
1718
provider_callback: Callable,
1819
provider_url: str,
19-
instance_type_callback: Callable,
20-
instance_type_url: str,
20+
instance_type_region_1_callback: Callable,
21+
instance_type_region_1_url: str,
2122
region_callback: Callable,
2223
region_url: str,
2324
settings: Settings,
@@ -32,7 +33,9 @@ def test_engine_create(
3233
):
3334
httpx_mock.add_callback(auth_callback, url=auth_url)
3435
httpx_mock.add_callback(provider_callback, url=provider_url)
35-
httpx_mock.add_callback(instance_type_callback, url=instance_type_url)
36+
httpx_mock.add_callback(
37+
instance_type_region_1_callback, url=instance_type_region_1_url
38+
)
3639
httpx_mock.add_callback(account_id_callback, url=account_id_url)
3740
httpx_mock.add_callback(auth_callback, url=auth_url)
3841
httpx_mock.add_callback(region_callback, url=region_url)
@@ -44,14 +47,44 @@ def test_engine_create(
4447
assert engine.name == engine_name
4548

4649

50+
def test_engine_create_no_available_types(
51+
httpx_mock: HTTPXMock,
52+
auth_callback: Callable,
53+
auth_url: str,
54+
provider_callback: Callable,
55+
provider_url: str,
56+
instance_type_empty_callback: Callable,
57+
instance_type_region_2_url: str,
58+
settings: Settings,
59+
mock_instance_types: List[InstanceType],
60+
engine_name: str,
61+
account_id_callback: Callable,
62+
account_id_url: str,
63+
engine_url: str,
64+
region_2: Region,
65+
):
66+
httpx_mock.add_callback(auth_callback, url=auth_url)
67+
httpx_mock.add_callback(provider_callback, url=provider_url)
68+
httpx_mock.add_callback(
69+
instance_type_empty_callback, url=instance_type_region_2_url
70+
)
71+
httpx_mock.add_callback(account_id_callback, url=account_id_url)
72+
httpx_mock.add_callback(auth_callback, url=auth_url)
73+
74+
manager = ResourceManager(settings=settings)
75+
76+
with raises(FireboltError):
77+
manager.engines.create(name=engine_name, region=region_2)
78+
79+
4780
def test_engine_no_attached_database(
4881
httpx_mock: HTTPXMock,
4982
auth_callback: Callable,
5083
auth_url: str,
5184
provider_callback: Callable,
5285
provider_url: str,
53-
instance_type_callback: Callable,
54-
instance_type_url: str,
86+
instance_type_region_1_callback: Callable,
87+
instance_type_region_1_url: str,
5588
region_callback: Callable,
5689
region_url: str,
5790
settings: Settings,
@@ -72,7 +105,9 @@ def test_engine_no_attached_database(
72105
):
73106
httpx_mock.add_callback(auth_callback, url=auth_url)
74107
httpx_mock.add_callback(provider_callback, url=provider_url)
75-
httpx_mock.add_callback(instance_type_callback, url=instance_type_url)
108+
httpx_mock.add_callback(
109+
instance_type_region_1_callback, url=instance_type_region_1_url
110+
)
76111
httpx_mock.add_callback(account_id_callback, url=account_id_url)
77112
httpx_mock.add_callback(auth_callback, url=auth_url)
78113
httpx_mock.add_callback(region_callback, url=region_url)
@@ -92,8 +127,8 @@ def test_engine_start_binding_to_missing_database(
92127
auth_url: str,
93128
provider_callback: Callable,
94129
provider_url: str,
95-
instance_type_callback: Callable,
96-
instance_type_url: str,
130+
instance_type_region_1_callback: Callable,
131+
instance_type_region_1_url: str,
97132
region_callback: Callable,
98133
region_url: str,
99134
settings: Settings,
@@ -112,7 +147,9 @@ def test_engine_start_binding_to_missing_database(
112147
):
113148
httpx_mock.add_callback(auth_callback, url=auth_url)
114149
httpx_mock.add_callback(provider_callback, url=provider_url)
115-
httpx_mock.add_callback(instance_type_callback, url=instance_type_url)
150+
httpx_mock.add_callback(
151+
instance_type_region_1_callback, url=instance_type_region_1_url
152+
)
116153
httpx_mock.add_callback(account_id_callback, url=account_id_url)
117154
httpx_mock.add_callback(auth_callback, url=auth_url)
118155
httpx_mock.add_callback(region_callback, url=region_url)
@@ -133,8 +170,8 @@ def test_get_connection(
133170
auth_url: str,
134171
provider_callback: Callable,
135172
provider_url: str,
136-
instance_type_callback: Callable,
137-
instance_type_url: str,
173+
instance_type_region_1_callback: Callable,
174+
instance_type_region_1_url: str,
138175
region_callback: Callable,
139176
region_url: str,
140177
settings: Settings,
@@ -154,7 +191,9 @@ def test_get_connection(
154191
):
155192
httpx_mock.add_callback(auth_callback, url=auth_url)
156193
httpx_mock.add_callback(provider_callback, url=provider_url)
157-
httpx_mock.add_callback(instance_type_callback, url=instance_type_url)
194+
httpx_mock.add_callback(
195+
instance_type_region_1_callback, url=instance_type_region_1_url
196+
)
158197
httpx_mock.add_callback(account_id_callback, url=account_id_url)
159198
httpx_mock.add_callback(auth_callback, url=auth_url)
160199
httpx_mock.add_callback(region_callback, url=region_url)
@@ -178,8 +217,8 @@ def test_attach_to_database(
178217
provider_url: str,
179218
region_callback: Callable,
180219
region_url: str,
181-
instance_type_callback: Callable,
182-
instance_type_url: str,
220+
instance_type_region_1_callback: Callable,
221+
instance_type_region_1_url: str,
183222
settings: Settings,
184223
account_id_callback: Callable,
185224
account_id_url: str,
@@ -200,7 +239,9 @@ def test_attach_to_database(
200239
):
201240
httpx_mock.add_callback(auth_callback, url=auth_url)
202241
httpx_mock.add_callback(provider_callback, url=provider_url)
203-
httpx_mock.add_callback(instance_type_callback, url=instance_type_url)
242+
httpx_mock.add_callback(
243+
instance_type_region_1_callback, url=instance_type_region_1_url
244+
)
204245
httpx_mock.add_callback(account_id_callback, url=account_id_url)
205246
httpx_mock.add_callback(auth_callback, url=auth_url)
206247
httpx_mock.add_callback(bindings_callback, url=bindings_url)

0 commit comments

Comments
 (0)