Sort locations based on store weight

Related to blueprint store-weight

Change-Id: I2383a476cb7e79c7efecdf33203cff0b50ef3bbb
This commit is contained in:
Abhishek Kekane 2023-06-23 09:53:07 +00:00
parent 46c30f0b6d
commit fd222f3128
11 changed files with 228 additions and 5 deletions

View File

@ -37,6 +37,9 @@ stores-detail:
for more information.) for more information.)
``read-only`` (optional) ``read-only`` (optional)
Included only when the store is read only. Included only when the store is read only.
``weight`` (default 0)
Contains weight (positive integer) to sort image locations for
preference.
``properties`` ``properties``
Contains store specific properties Contains store specific properties
in: body in: body

View File

@ -5,6 +5,7 @@
"type": "rbd", "type": "rbd",
"description": "More expensive store with data redundancy", "description": "More expensive store with data redundancy",
"default": true, "default": true,
"weight": 100,
"properties": { "properties": {
"pool": "pool1", "pool": "pool1",
"chunk_size": 65536, "chunk_size": 65536,
@ -15,6 +16,7 @@
"id":"cheap", "id":"cheap",
"type": "file", "type": "file",
"description": "Less expensive store for seldom-used images", "description": "Less expensive store for seldom-used images",
"weight": 200,
"properties": { "properties": {
"datadir": "fdir", "datadir": "fdir",
"chunk_size": 65536, "chunk_size": 65536,
@ -25,6 +27,7 @@
"id":"fast", "id":"fast",
"type": "cinder", "type": "cinder",
"description": "Reasonably-priced fast store", "description": "Reasonably-priced fast store",
"weight": 300,
"properties": { "properties": {
"volume_type": "volume1", "volume_type": "volume1",
"use_multipath": false "use_multipath": false
@ -34,6 +37,7 @@
"id":"slow", "id":"slow",
"type": "swift", "type": "swift",
"description": "Entry-level store balancing price and speed", "description": "Entry-level store balancing price and speed",
"weight": 400,
"properties": { "properties": {
"container": "container1", "container": "container1",
"large_object_size": 52428, "large_object_size": 52428,

View File

@ -149,6 +149,7 @@ class InfoController(object):
store['id']) store['id'])
store['properties'] = store_mapper.get(store_type)( store['properties'] = store_mapper.get(store_type)(
store_detail) store_detail)
store['weight'] = getattr(CONF, store['id']).weight
except exception.Forbidden as e: except exception.Forbidden as e:
LOG.debug("User not permitted to view details") LOG.debug("User not permitted to view details")

View File

@ -36,7 +36,6 @@ from glance.api import common
from glance.api import policy from glance.api import policy
from glance.api.v2 import policy as api_policy from glance.api.v2 import policy as api_policy
from glance.common import exception from glance.common import exception
from glance.common import location_strategy
from glance.common import store_utils from glance.common import store_utils
from glance.common import timeutils from glance.common import timeutils
from glance.common import utils from glance.common import utils
@ -1610,7 +1609,7 @@ class ResponseSerializer(wsgi.JSONResponseSerializer):
locations = _get_image_locations(image) locations = _get_image_locations(image)
if locations: if locations:
# Choose best location configured strategy # Choose best location configured strategy
loc = location_strategy.choose_best_location(locations) loc = utils.sort_image_locations(locations)[0]
image_view['direct_url'] = loc['url'] image_view['direct_url'] = loc['url']
else: else:
LOG.debug("The 'locations' list of image %s is empty, " LOG.debug("The 'locations' list of image %s is empty, "

View File

@ -42,9 +42,10 @@ from oslo_utils import strutils
from webob import exc from webob import exc
from glance.common import exception from glance.common import exception
from glance.common import location_strategy
from glance.common import timeutils from glance.common import timeutils
from glance.common import wsgi from glance.common import wsgi
from glance.i18n import _, _LE from glance.i18n import _, _LE, _LW
CONF = cfg.CONF CONF = cfg.CONF
@ -712,3 +713,25 @@ def get_stores_from_request(req, body):
for store in stores: for store in stores:
glance_store.get_store_from_store_identifier(store) glance_store.get_store_from_store_identifier(store)
return stores return stores
def sort_image_locations(locations):
if not CONF.enabled_backends:
return location_strategy.get_ordered_locations(locations)
def get_store_weight(location):
store_id = location['metadata'].get('store')
if not store_id:
return 0
try:
store = glance_store.get_store_from_store_identifier(store_id)
except glance_store.exceptions.UnknownScheme:
msg = (_LW("Unable to find store '%s', returning "
"default weight '0'") % store_id)
LOG.warning(msg)
return 0
return store.weight if store is not None else 0
sorted_locations = sorted(locations, key=get_store_weight, reverse=True)
LOG.debug(('Sorted locations: %s'), sorted_locations)
return sorted_locations

View File

@ -24,7 +24,7 @@ from wsme.rest import json
from glance.api.v2.model.metadef_property_type import PropertyType from glance.api.v2.model.metadef_property_type import PropertyType
from glance.common import crypt from glance.common import crypt
from glance.common import exception from glance.common import exception
from glance.common import location_strategy from glance.common import utils as common_utils
import glance.domain import glance.domain
import glance.domain.proxy import glance.domain.proxy
from glance.i18n import _ from glance.i18n import _
@ -127,7 +127,7 @@ class ImageRepo(object):
min_disk=db_image['min_disk'], min_disk=db_image['min_disk'],
min_ram=db_image['min_ram'], min_ram=db_image['min_ram'],
protected=db_image['protected'], protected=db_image['protected'],
locations=location_strategy.get_ordered_locations(locations), locations=common_utils.sort_image_locations(locations),
checksum=db_image['checksum'], checksum=db_image['checksum'],
os_hash_algo=db_image['os_hash_algo'], os_hash_algo=db_image['os_hash_algo'],
os_hash_value=db_image['os_hash_value'], os_hash_value=db_image['os_hash_value'],

View File

@ -131,6 +131,7 @@ class TestDiscovery(functional.SynchronousAPIBase):
"id": "store1", "id": "store1",
"default": "true", "default": "true",
"type": "file", "type": "file",
"weight": 0,
"properties": { "properties": {
"data_dir": self._store_dir('store1'), "data_dir": self._store_dir('store1'),
"chunk_size": 65536, "chunk_size": 65536,
@ -140,6 +141,7 @@ class TestDiscovery(functional.SynchronousAPIBase):
{ {
"id": "store2", "id": "store2",
"type": "file", "type": "file",
"weight": 0,
"properties": { "properties": {
"data_dir": self._store_dir('store2'), "data_dir": self._store_dir('store2'),
"chunk_size": 65536, "chunk_size": 65536,
@ -149,6 +151,7 @@ class TestDiscovery(functional.SynchronousAPIBase):
{ {
"id": "store3", "id": "store3",
"type": "file", "type": "file",
"weight": 0,
"properties": { "properties": {
"data_dir": self._store_dir('store3'), "data_dir": self._store_dir('store3'),
"chunk_size": 65536, "chunk_size": 65536,

View File

@ -7318,3 +7318,38 @@ class TestKeystoneQuotas(functional.SynchronousAPIBase):
# Make sure we can still import. # Make sure we can still import.
self._create_and_import(stores=['store1']) self._create_and_import(stores=['store1'])
class TestStoreWeight(functional.SynchronousAPIBase):
def setUp(self):
super(TestStoreWeight, self).setUp()
def test_store_weight_combinations(self):
self.start_server()
# Import image in all available stores
image_id = self._create_and_import(stores=['store1', 'store2',
'store3'])
# make sure as weight is default, we will get locations based
# on insertion order
image = self.api_get('/v2/images/%s' % image_id).json
self.assertEqual("store1,store2,store3", image['stores'])
# give highest weight to store2 then store3 and then store1
self.config(weight=200, group='store2')
self.config(weight=100, group='store3')
self.config(weight=50, group='store1')
self.start_server()
# make sure as per store weight locations will be sorted
# as store2,store3,store1
image = self.api_get('/v2/images/%s' % image_id).json
self.assertEqual("store2,store3,store1", image['stores'])
# give highest weight to store3 then store1 and then store2
self.config(weight=20, group='store2')
self.config(weight=100, group='store3')
self.config(weight=50, group='store1')
self.start_server()
# make sure as per store weight locations will be sorted
# as store3,store1,store2
image = self.api_get('/v2/images/%s' % image_id).json
self.assertEqual("store3,store1,store2", image['stores'])

View File

@ -155,6 +155,139 @@ class TestCinderStoreUtils(base.MultiStoreClearingUnitTest):
class TestUtils(test_utils.BaseTestCase): class TestUtils(test_utils.BaseTestCase):
"""Test routines in glance.utils""" """Test routines in glance.utils"""
def test_sort_image_locations_multistore_disabled(self):
self.config(enabled_backends=None)
locations = [{
'url': 'rbd://aaaaaaaa/images/id',
'metadata': {'store': 'rbd1'}
}, {
'url': 'rbd://bbbbbbbb/images/id',
'metadata': {'store': 'rbd2'}
}, {
'url': 'rbd://cccccccc/images/id',
'metadata': {'store': 'rbd3'}
}]
mp = "glance.common.utils.glance_store.get_store_from_store_identifier"
with mock.patch(mp) as mock_get_store:
utils.sort_image_locations(locations)
# Since multistore is not enabled, it will not sort the locations
self.assertEqual(0, mock_get_store.call_count)
def test_sort_image_locations(self):
enabled_backends = {
"rbd1": "rbd",
"rbd2": "rbd",
"rbd3": "rbd"
}
self.config(enabled_backends=enabled_backends)
store.register_store_opts(CONF)
self.config(default_backend="rbd1", group="glance_store")
locations = [{
'url': 'rbd://aaaaaaaa/images/id',
'metadata': {'store': 'rbd1'}
}, {
'url': 'rbd://bbbbbbbb/images/id',
'metadata': {'store': 'rbd2'}
}, {
'url': 'rbd://cccccccc/images/id',
'metadata': {'store': 'rbd3'}
}]
mp = "glance.common.utils.glance_store.get_store_from_store_identifier"
with mock.patch(mp) as mock_get_store:
mock_store = mock_get_store.return_value
mock_store.weight = 100
utils.sort_image_locations(locations)
# Since 3 stores are configured, internal method will be called 3 times
self.assertEqual(3, mock_get_store.call_count)
def test_sort_image_locations_without_metadata(self):
enabled_backends = {
"rbd1": "rbd",
"rbd2": "rbd",
"rbd3": "rbd"
}
self.config(enabled_backends=enabled_backends)
store.register_store_opts(CONF)
self.config(default_backend="rbd1", group="glance_store")
locations = [{
'url': 'rbd://aaaaaaaa/images/id',
'metadata': {}
}, {
'url': 'rbd://bbbbbbbb/images/id',
'metadata': {}
}, {
'url': 'rbd://cccccccc/images/id',
'metadata': {}
}]
mp = "glance.common.utils.glance_store.get_store_from_store_identifier"
with mock.patch(mp) as mock_get_store:
utils.sort_image_locations(locations)
# Since 3 stores are configured, without store in metadata the internal
# method will be called 0 times
self.assertEqual(0, mock_get_store.call_count)
def test_sort_image_locations_with_partial_metadata(self):
enabled_backends = {
"rbd1": "rbd",
"rbd2": "rbd",
"rbd3": "rbd"
}
self.config(enabled_backends=enabled_backends)
store.register_store_opts(CONF)
self.config(default_backend="rbd1", group="glance_store")
locations = [{
'url': 'rbd://aaaaaaaa/images/id',
'metadata': {'store': 'rbd1'}
}, {
'url': 'rbd://bbbbbbbb/images/id',
'metadata': {}
}, {
'url': 'rbd://cccccccc/images/id',
'metadata': {}
}]
mp = "glance.common.utils.glance_store.get_store_from_store_identifier"
with mock.patch(mp) as mock_get_store:
mock_store = mock_get_store.return_value
mock_store.weight = 100
utils.sort_image_locations(locations)
# Since 3 stores are configured, but only one location has
# store in metadata the internal
# method will be called 1 time only
self.assertEqual(1, mock_get_store.call_count)
def test_sort_image_locations_unknownscheme(self):
enabled_backends = {
"rbd1": "rbd",
"rbd2": "rbd",
"rbd3": "rbd"
}
self.config(enabled_backends=enabled_backends)
store.register_store_opts(CONF)
self.config(default_backend="rbd1", group="glance_store")
locations = [{
'url': 'rbd://aaaaaaaa/images/id',
'metadata': {'store': 'rbd1'}
}, {
'url': 'rbd://bbbbbbbb/images/id',
'metadata': {'store': 'rbd2'}
}, {
'url': 'rbd://cccccccc/images/id',
'metadata': {'store': 'rbd3'}
}]
mp = "glance.common.utils.glance_store.get_store_from_store_identifier"
with mock.patch(mp) as mock_get_store:
mock_get_store.side_effect = store.UnknownScheme()
sorted_locations = utils.sort_image_locations(locations)
# Even though UnknownScheme exception is raised, processing continues
self.assertEqual(3, mock_get_store.call_count)
# Since we return 0 weight, original location order should be preserved
self.assertEqual(locations, sorted_locations)
def test_cooperative_reader(self): def test_cooperative_reader(self):
"""Ensure cooperative reader class accesses all bytes of file""" """Ensure cooperative reader class accesses all bytes of file"""
BYTES = 1024 BYTES = 1024

View File

@ -46,6 +46,7 @@ class TestInfoControllers(base.MultiStoreClearingUnitTest):
self.assertIn('stores', output) self.assertIn('stores', output)
for stores in output['stores']: for stores in output['stores']:
self.assertIn('id', stores) self.assertIn('id', stores)
self.assertNotIn('weight', stores)
self.assertIn(stores['id'], available_stores) self.assertIn(stores['id'], available_stores)
def test_get_stores_read_only_store(self): def test_get_stores_read_only_store(self):
@ -108,6 +109,20 @@ class TestInfoControllers(base.MultiStoreClearingUnitTest):
expected_attribute = store_attributes[store['type']] expected_attribute = store_attributes[store['type']]
self.assertEqual(actual_attribute, expected_attribute) self.assertEqual(actual_attribute, expected_attribute)
def test_get_stores_detail_with_store_weight(self):
self.config(weight=100, group='fast')
self.config(weight=200, group='cheap')
self.config(weight=300, group='fast-rbd')
self.config(weight=400, group='fast-cinder')
self.config(weight=500, group='reliable')
req = unit_test_utils.get_fake_request(roles=['admin'])
output = self.controller.get_stores_detail(req)
self.assertEqual(len(CONF.enabled_backends), len(output['stores']))
self.assertIn('stores', output)
for store in output['stores']:
self.assertIn('weight', store)
def test_get_stores_detail_non_admin(self): def test_get_stores_detail_non_admin(self):
req = unit_test_utils.get_fake_request() req = unit_test_utils.get_fake_request()
self.assertRaises(webob.exc.HTTPForbidden, self.assertRaises(webob.exc.HTTPForbidden,

View File

@ -0,0 +1,7 @@
---
features:
- |
"GET" images API will now sort image locations based on store weight
configured for each store in configuration files. Image will be
downloaded from the store having highest weight configured. For default
weight scenario the locations will remain same as per insertion order.