Skip to content

Commit 44aa7cc

Browse files
zoercailarkee
andauthored
feat: add support for low-cost instances (#313)
* Add LCI implementation * Update google/cloud/spanner_v1/instance.py Co-authored-by: larkee <31196561+larkee@users.noreply.github.com> * Fix docstring format * Update google/cloud/spanner_v1/instance.py Co-authored-by: larkee <31196561+larkee@users.noreply.github.com> Co-authored-by: larkee <31196561+larkee@users.noreply.github.com>
1 parent b8b24e1 commit 44aa7cc

File tree

5 files changed

+177
-18
lines changed

5 files changed

+177
-18
lines changed

google/cloud/spanner_v1/client.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,6 @@
4949
from google.cloud.client import ClientWithProject
5050
from google.cloud.spanner_v1 import __version__
5151
from google.cloud.spanner_v1._helpers import _merge_query_options, _metadata_with_prefix
52-
from google.cloud.spanner_v1.instance import DEFAULT_NODE_COUNT
5352
from google.cloud.spanner_v1.instance import Instance
5453
from google.cloud.spanner_v1 import ExecuteSqlRequest
5554
from google.cloud.spanner_admin_instance_v1 import ListInstanceConfigsRequest
@@ -294,8 +293,9 @@ def instance(
294293
instance_id,
295294
configuration_name=None,
296295
display_name=None,
297-
node_count=DEFAULT_NODE_COUNT,
296+
node_count=None,
298297
labels=None,
298+
processing_units=None,
299299
):
300300
"""Factory to create a instance associated with this client.
301301
@@ -320,6 +320,10 @@ def instance(
320320
:param node_count: (Optional) The number of nodes in the instance's
321321
cluster; used to set up the instance's cluster.
322322
323+
:type processing_units: int
324+
:param processing_units: (Optional) The number of processing units
325+
allocated to this instance.
326+
323327
:type labels: dict (str -> str) or None
324328
:param labels: (Optional) User-assigned labels for this instance.
325329
@@ -334,6 +338,7 @@ def instance(
334338
display_name,
335339
self._emulator_host,
336340
labels,
341+
processing_units,
337342
)
338343

339344
def list_instances(self, filter_="", page_size=None):

google/cloud/spanner_v1/instance.py

Lines changed: 74 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"""User friendly container for Cloud Spanner Instance."""
1616

1717
import google.api_core.operation
18+
from google.api_core.exceptions import InvalidArgument
1819
import re
1920

2021
from google.cloud.spanner_admin_instance_v1 import Instance as InstancePB
@@ -41,6 +42,7 @@
4142
)
4243

4344
DEFAULT_NODE_COUNT = 1
45+
PROCESSING_UNITS_PER_NODE = 1000
4446

4547
_OPERATION_METADATA_MESSAGES = (
4648
backup.Backup,
@@ -95,6 +97,10 @@ class Instance(object):
9597
:type node_count: int
9698
:param node_count: (Optional) Number of nodes allocated to the instance.
9799
100+
:type processing_units: int
101+
:param processing_units: (Optional) The number of processing units
102+
allocated to this instance.
103+
98104
:type display_name: str
99105
:param display_name: (Optional) The display name for the instance in the
100106
Cloud Console UI. (Must be between 4 and 30
@@ -110,15 +116,29 @@ def __init__(
110116
instance_id,
111117
client,
112118
configuration_name=None,
113-
node_count=DEFAULT_NODE_COUNT,
119+
node_count=None,
114120
display_name=None,
115121
emulator_host=None,
116122
labels=None,
123+
processing_units=None,
117124
):
118125
self.instance_id = instance_id
119126
self._client = client
120127
self.configuration_name = configuration_name
121-
self.node_count = node_count
128+
if node_count is not None and processing_units is not None:
129+
if processing_units != node_count * PROCESSING_UNITS_PER_NODE:
130+
raise InvalidArgument(
131+
"Only one of node count and processing units can be set."
132+
)
133+
if node_count is None and processing_units is None:
134+
self._node_count = DEFAULT_NODE_COUNT
135+
self._processing_units = DEFAULT_NODE_COUNT * PROCESSING_UNITS_PER_NODE
136+
elif node_count is not None:
137+
self._node_count = node_count
138+
self._processing_units = node_count * PROCESSING_UNITS_PER_NODE
139+
else:
140+
self._processing_units = processing_units
141+
self._node_count = processing_units // PROCESSING_UNITS_PER_NODE
122142
self.display_name = display_name or instance_id
123143
self.emulator_host = emulator_host
124144
if labels is None:
@@ -134,7 +154,8 @@ def _update_from_pb(self, instance_pb):
134154
raise ValueError("Instance protobuf does not contain display_name")
135155
self.display_name = instance_pb.display_name
136156
self.configuration_name = instance_pb.config
137-
self.node_count = instance_pb.node_count
157+
self._node_count = instance_pb.node_count
158+
self._processing_units = instance_pb.processing_units
138159
self.labels = instance_pb.labels
139160

140161
@classmethod
@@ -190,6 +211,44 @@ def name(self):
190211
"""
191212
return self._client.project_name + "/instances/" + self.instance_id
192213

214+
@property
215+
def processing_units(self):
216+
"""Processing units used in requests.
217+
218+
:rtype: int
219+
:returns: The number of processing units allocated to this instance.
220+
"""
221+
return self._processing_units
222+
223+
@processing_units.setter
224+
def processing_units(self, value):
225+
"""Sets the processing units for requests. Affects node_count.
226+
227+
:param value: The number of processing units allocated to this instance.
228+
"""
229+
self._processing_units = value
230+
self._node_count = value // PROCESSING_UNITS_PER_NODE
231+
232+
@property
233+
def node_count(self):
234+
"""Node count used in requests.
235+
236+
:rtype: int
237+
:returns:
238+
The number of nodes in the instance's cluster;
239+
used to set up the instance's cluster.
240+
"""
241+
return self._node_count
242+
243+
@node_count.setter
244+
def node_count(self, value):
245+
"""Sets the node count for requests. Affects processing_units.
246+
247+
:param value: The number of nodes in the instance's cluster.
248+
"""
249+
self._node_count = value
250+
self._processing_units = value * PROCESSING_UNITS_PER_NODE
251+
193252
def __eq__(self, other):
194253
if not isinstance(other, self.__class__):
195254
return NotImplemented
@@ -218,7 +277,8 @@ def copy(self):
218277
self.instance_id,
219278
new_client,
220279
self.configuration_name,
221-
node_count=self.node_count,
280+
node_count=self._node_count,
281+
processing_units=self._processing_units,
222282
display_name=self.display_name,
223283
)
224284

@@ -250,7 +310,7 @@ def create(self):
250310
name=self.name,
251311
config=self.configuration_name,
252312
display_name=self.display_name,
253-
node_count=self.node_count,
313+
processing_units=self._processing_units,
254314
labels=self.labels,
255315
)
256316
metadata = _metadata_with_prefix(self.name)
@@ -306,8 +366,8 @@ def update(self):
306366
307367
.. note::
308368
309-
Updates the ``display_name``, ``node_count`` and ``labels``. To change those
310-
values before updating, set them via
369+
Updates the ``display_name``, ``node_count``, ``processing_units``
370+
and ``labels``. To change those values before updating, set them via
311371
312372
.. code:: python
313373
@@ -325,10 +385,15 @@ def update(self):
325385
name=self.name,
326386
config=self.configuration_name,
327387
display_name=self.display_name,
328-
node_count=self.node_count,
388+
node_count=self._node_count,
389+
processing_units=self._processing_units,
329390
labels=self.labels,
330391
)
331-
field_mask = FieldMask(paths=["config", "display_name", "node_count", "labels"])
392+
393+
# Always update only processing_units, not nodes
394+
field_mask = FieldMask(
395+
paths=["config", "display_name", "processing_units", "labels"]
396+
)
332397
metadata = _metadata_with_prefix(self.name)
333398

334399
future = api.update_instance(

tests/system/test_system.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,35 @@ def test_create_instance(self):
229229
self.assertEqual(instance, instance_alt)
230230
self.assertEqual(instance.display_name, instance_alt.display_name)
231231

232+
@unittest.skipIf(USE_EMULATOR, "Skipping LCI tests")
233+
@unittest.skipUnless(CREATE_INSTANCE, "Skipping instance creation")
234+
def test_create_instance_with_processing_nodes(self):
235+
ALT_INSTANCE_ID = "new" + unique_resource_id("-")
236+
PROCESSING_UNITS = 5000
237+
instance = Config.CLIENT.instance(
238+
instance_id=ALT_INSTANCE_ID,
239+
configuration_name=Config.INSTANCE_CONFIG.name,
240+
processing_units=PROCESSING_UNITS,
241+
)
242+
operation = instance.create()
243+
# Make sure this instance gets deleted after the test case.
244+
self.instances_to_delete.append(instance)
245+
246+
# We want to make sure the operation completes.
247+
operation.result(
248+
SPANNER_OPERATION_TIMEOUT_IN_SECONDS
249+
) # raises on failure / timeout.
250+
251+
# Create a new instance instance and make sure it is the same.
252+
instance_alt = Config.CLIENT.instance(
253+
ALT_INSTANCE_ID, Config.INSTANCE_CONFIG.name
254+
)
255+
instance_alt.reload()
256+
257+
self.assertEqual(instance, instance_alt)
258+
self.assertEqual(instance.display_name, instance_alt.display_name)
259+
self.assertEqual(instance.processing_units, instance_alt.processing_units)
260+
232261
@unittest.skipIf(USE_EMULATOR, "Skipping updating instance")
233262
def test_update_instance(self):
234263
OLD_DISPLAY_NAME = Config.INSTANCE.display_name

tests/unit/test_client.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ class TestClient(unittest.TestCase):
3737
INSTANCE_NAME = "%s/instances/%s" % (PATH, INSTANCE_ID)
3838
DISPLAY_NAME = "display-name"
3939
NODE_COUNT = 5
40+
PROCESSING_UNITS = 5000
4041
LABELS = {"test": "true"}
4142
TIMEOUT_SECONDS = 80
4243

@@ -580,6 +581,7 @@ def test_list_instances(self):
580581
config=self.CONFIGURATION_NAME,
581582
display_name=self.DISPLAY_NAME,
582583
node_count=self.NODE_COUNT,
584+
processing_units=self.PROCESSING_UNITS,
583585
)
584586
]
585587
)
@@ -597,6 +599,7 @@ def test_list_instances(self):
597599
self.assertEqual(instance.config, self.CONFIGURATION_NAME)
598600
self.assertEqual(instance.display_name, self.DISPLAY_NAME)
599601
self.assertEqual(instance.node_count, self.NODE_COUNT)
602+
self.assertEqual(instance.processing_units, self.PROCESSING_UNITS)
600603

601604
expected_metadata = (
602605
("google-cloud-resource-prefix", client.project_name),

tests/unit/test_instance.py

Lines changed: 64 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ class TestInstance(unittest.TestCase):
2727
LOCATION = "projects/" + PROJECT + "/locations/" + CONFIG_NAME
2828
DISPLAY_NAME = "display_name"
2929
NODE_COUNT = 5
30+
PROCESSING_UNITS = 5000
3031
OP_ID = 8915
3132
OP_NAME = "operations/projects/%s/instances/%soperations/%d" % (
3233
PROJECT,
@@ -39,6 +40,7 @@ class TestInstance(unittest.TestCase):
3940
DATABASE_ID = "database_id"
4041
DATABASE_NAME = "%s/databases/%s" % (INSTANCE_NAME, DATABASE_ID)
4142
LABELS = {"test": "true"}
43+
FIELD_MASK = ["config", "display_name", "processing_units", "labels"]
4244

4345
def _getTargetClass(self):
4446
from google.cloud.spanner_v1.instance import Instance
@@ -230,7 +232,7 @@ def test_create_already_exists(self):
230232
self.assertEqual(instance.name, self.INSTANCE_NAME)
231233
self.assertEqual(instance.config, self.CONFIG_NAME)
232234
self.assertEqual(instance.display_name, self.INSTANCE_ID)
233-
self.assertEqual(instance.node_count, 1)
235+
self.assertEqual(instance.processing_units, 1000)
234236
self.assertEqual(metadata, [("google-cloud-resource-prefix", instance.name)])
235237

236238
def test_create_success(self):
@@ -258,7 +260,36 @@ def test_create_success(self):
258260
self.assertEqual(instance.name, self.INSTANCE_NAME)
259261
self.assertEqual(instance.config, self.CONFIG_NAME)
260262
self.assertEqual(instance.display_name, self.DISPLAY_NAME)
261-
self.assertEqual(instance.node_count, self.NODE_COUNT)
263+
self.assertEqual(instance.processing_units, self.PROCESSING_UNITS)
264+
self.assertEqual(instance.labels, self.LABELS)
265+
self.assertEqual(metadata, [("google-cloud-resource-prefix", instance.name)])
266+
267+
def test_create_with_processing_units(self):
268+
op_future = _FauxOperationFuture()
269+
client = _Client(self.PROJECT)
270+
api = client.instance_admin_api = _FauxInstanceAdminAPI(
271+
_create_instance_response=op_future
272+
)
273+
instance = self._make_one(
274+
self.INSTANCE_ID,
275+
client,
276+
configuration_name=self.CONFIG_NAME,
277+
display_name=self.DISPLAY_NAME,
278+
processing_units=self.PROCESSING_UNITS,
279+
labels=self.LABELS,
280+
)
281+
282+
future = instance.create()
283+
284+
self.assertIs(future, op_future)
285+
286+
(parent, instance_id, instance, metadata) = api._created_instance
287+
self.assertEqual(parent, self.PARENT)
288+
self.assertEqual(instance_id, self.INSTANCE_ID)
289+
self.assertEqual(instance.name, self.INSTANCE_NAME)
290+
self.assertEqual(instance.config, self.CONFIG_NAME)
291+
self.assertEqual(instance.display_name, self.DISPLAY_NAME)
292+
self.assertEqual(instance.processing_units, self.PROCESSING_UNITS)
262293
self.assertEqual(instance.labels, self.LABELS)
263294
self.assertEqual(metadata, [("google-cloud-resource-prefix", instance.name)])
264295

@@ -389,9 +420,7 @@ def test_update_not_found(self):
389420
instance.update()
390421

391422
instance, field_mask, metadata = api._updated_instance
392-
self.assertEqual(
393-
field_mask.paths, ["config", "display_name", "node_count", "labels"]
394-
)
423+
self.assertEqual(field_mask.paths, self.FIELD_MASK)
395424
self.assertEqual(instance.name, self.INSTANCE_NAME)
396425
self.assertEqual(instance.config, self.CONFIG_NAME)
397426
self.assertEqual(instance.display_name, self.INSTANCE_ID)
@@ -417,14 +446,42 @@ def test_update_success(self):
417446

418447
self.assertIs(future, op_future)
419448

449+
instance, field_mask, metadata = api._updated_instance
450+
self.assertEqual(field_mask.paths, self.FIELD_MASK)
451+
self.assertEqual(instance.name, self.INSTANCE_NAME)
452+
self.assertEqual(instance.config, self.CONFIG_NAME)
453+
self.assertEqual(instance.display_name, self.DISPLAY_NAME)
454+
self.assertEqual(instance.node_count, self.NODE_COUNT)
455+
self.assertEqual(instance.labels, self.LABELS)
456+
self.assertEqual(metadata, [("google-cloud-resource-prefix", instance.name)])
457+
458+
def test_update_success_with_processing_units(self):
459+
op_future = _FauxOperationFuture()
460+
client = _Client(self.PROJECT)
461+
api = client.instance_admin_api = _FauxInstanceAdminAPI(
462+
_update_instance_response=op_future
463+
)
464+
instance = self._make_one(
465+
self.INSTANCE_ID,
466+
client,
467+
configuration_name=self.CONFIG_NAME,
468+
processing_units=self.PROCESSING_UNITS,
469+
display_name=self.DISPLAY_NAME,
470+
labels=self.LABELS,
471+
)
472+
473+
future = instance.update()
474+
475+
self.assertIs(future, op_future)
476+
420477
instance, field_mask, metadata = api._updated_instance
421478
self.assertEqual(
422-
field_mask.paths, ["config", "display_name", "node_count", "labels"]
479+
field_mask.paths, ["config", "display_name", "processing_units", "labels"]
423480
)
424481
self.assertEqual(instance.name, self.INSTANCE_NAME)
425482
self.assertEqual(instance.config, self.CONFIG_NAME)
426483
self.assertEqual(instance.display_name, self.DISPLAY_NAME)
427-
self.assertEqual(instance.node_count, self.NODE_COUNT)
484+
self.assertEqual(instance.processing_units, self.PROCESSING_UNITS)
428485
self.assertEqual(instance.labels, self.LABELS)
429486
self.assertEqual(metadata, [("google-cloud-resource-prefix", instance.name)])
430487

0 commit comments

Comments
 (0)