1
0
mirror of https://github.com/quay/quay.git synced 2025-07-28 20:22:05 +03:00

Optimize repository lookup queries to meet the expected maximums (#246)

* Optimize repository lookup queries to meet the expected maximums

We were accidentally looking up more data that strictly allowed

Adds some additional assertions and testing as well

Fixes https://issues.redhat.com/browse/PROJQUAY-439

* Change loading of repositories in the repo view to be paginated

We drop the "card" view and switch to a table-only view, but still
load the full set of repositories

A followup change will begin to change the UI to only load additional
repos when requested
This commit is contained in:
Joseph Schorr
2020-05-12 12:12:54 -04:00
committed by GitHub
parent 3f8221f74d
commit f2eaba7ef2
16 changed files with 129 additions and 98 deletions

View File

@ -203,6 +203,7 @@ def get_most_recent_tag_lifetime_start(repository_ids):
return {} return {}
assert len(repository_ids) > 0 and None not in repository_ids assert len(repository_ids) > 0 and None not in repository_ids
assert len(repository_ids) <= 100
query = ( query = (
Tag.select(Tag.repository, fn.Max(Tag.lifetime_start_ms)) Tag.select(Tag.repository, fn.Max(Tag.lifetime_start_ms))

View File

@ -351,8 +351,10 @@ def get_filtered_matching_repositories(
if filter_user is None: if filter_user is None:
return [] return []
# NOTE: We add the offset to the limit here to ensure we have enough results
# for the take's we conduct below.
iterator = _filter_repositories_visible_to_user( iterator = _filter_repositories_visible_to_user(
unfiltered_query, filter_user.id, limit, repo_kind unfiltered_query, filter_user.id, offset + limit, repo_kind
) )
if offset > 0: if offset > 0:
take(offset, iterator) take(offset, iterator)
@ -429,7 +431,7 @@ def _get_sorted_matching_repositories(
Repository.select(*select_fields) Repository.select(*select_fields)
.join(RepositorySearchScore) .join(RepositorySearchScore)
.where(Repository.state != RepositoryState.MARKED_FOR_DELETION) .where(Repository.state != RepositoryState.MARKED_FOR_DELETION)
.order_by(RepositorySearchScore.score.desc()) .order_by(RepositorySearchScore.score.desc(), RepositorySearchScore.id)
) )
else: else:
if search_fields is None: if search_fields is None:
@ -454,7 +456,7 @@ def _get_sorted_matching_repositories(
.join(RepositorySearchScore) .join(RepositorySearchScore)
.where(clause) .where(clause)
.where(Repository.state != RepositoryState.MARKED_FOR_DELETION) .where(Repository.state != RepositoryState.MARKED_FOR_DELETION)
.order_by(SQL("score").desc()) .order_by(SQL("score").desc(), RepositorySearchScore.id)
) )
if repo_kind is not None: if repo_kind is not None:

View File

@ -198,8 +198,9 @@ class RegistryDataInterface(object):
@abstractmethod @abstractmethod
def get_most_recent_tag_lifetime_start(self, repository_refs): def get_most_recent_tag_lifetime_start(self, repository_refs):
""" """
Returns a map from repository ID to the last modified time ( seconds from epoch, UTC) for Returns a map from repository ID to the last modified time (seconds from epoch, UTC) for
each repository in the given repository reference list. each repository in the given repository reference list. There can be a maximum of 100
repositories specified, as this is a VERY heavy operation.
""" """
@abstractmethod @abstractmethod

View File

@ -56,7 +56,6 @@ from util.parsing import truthy_bool
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
REPOS_PER_PAGE = 100
MAX_DAYS_IN_3_MONTHS = 92 MAX_DAYS_IN_3_MONTHS = 92

View File

@ -91,6 +91,7 @@ class PreOCIModel(RepositoryDataInterface):
popularity, popularity,
): ):
next_page_token = None next_page_token = None
# Lookup the requested repositories (either starred or non-starred.) # Lookup the requested repositories (either starred or non-starred.)
if starred: if starred:
# Return the full list of repos starred by the current user that are still visible to them. # Return the full list of repos starred by the current user that are still visible to them.
@ -103,17 +104,6 @@ class PreOCIModel(RepositoryDataInterface):
user, kind_filter=repo_kind user, kind_filter=repo_kind
) )
repos = [repo for repo in unfiltered_repos if can_view_repo(repo)] repos = [repo for repo in unfiltered_repos if can_view_repo(repo)]
elif namespace:
# Repositories filtered by namespace do not need pagination (their results are fairly small),
# so we just do the lookup directly.
repos = list(
model.repository.get_visible_repositories(
username=username,
include_public=public,
namespace=namespace,
kind_filter=repo_kind,
)
)
else: else:
# Determine the starting offset for pagination. Note that we don't use the normal # Determine the starting offset for pagination. Note that we don't use the normal
# model.modelutil.paginate method here, as that does not operate over UNION queries, which # model.modelutil.paginate method here, as that does not operate over UNION queries, which
@ -128,13 +118,17 @@ class PreOCIModel(RepositoryDataInterface):
start_id=start_id, start_id=start_id,
limit=REPOS_PER_PAGE + 1, limit=REPOS_PER_PAGE + 1,
kind_filter=repo_kind, kind_filter=repo_kind,
namespace=namespace,
) )
repos, next_page_token = model.modelutil.paginate_query( repos, next_page_token = model.modelutil.paginate_query(
repo_query, limit=REPOS_PER_PAGE, sort_field_name="rid" repo_query, limit=REPOS_PER_PAGE, sort_field_name="rid"
) )
# Collect the IDs of the repositories found for subequent lookup of popularity repos = list(repos)
assert len(repos) <= REPOS_PER_PAGE
# Collect the IDs of the repositories found for subsequent lookup of popularity
# and/or last modified. # and/or last modified.
last_modified_map = {} last_modified_map = {}
action_sum_map = {} action_sum_map = {}

View File

@ -408,6 +408,9 @@ class ConductRepositorySearch(ApiResource):
@parse_args() @parse_args()
@query_param("query", "The search query.", type=str, default="") @query_param("query", "The search query.", type=str, default="")
@query_param("page", "The page.", type=int, default=1) @query_param("page", "The page.", type=int, default=1)
@query_param(
"includeUsage", "Whether to include usage metadata", type=truthy_bool, default=False
)
@nickname("conductRepoSearch") @nickname("conductRepoSearch")
def get(self, parsed_args): def get(self, parsed_args):
""" """
@ -416,7 +419,7 @@ class ConductRepositorySearch(ApiResource):
query = parsed_args["query"] query = parsed_args["query"]
page = min(max(1, parsed_args["page"]), MAX_RESULT_PAGE_COUNT) page = min(max(1, parsed_args["page"]), MAX_RESULT_PAGE_COUNT)
offset = (page - 1) * MAX_PER_PAGE offset = (page - 1) * MAX_PER_PAGE
limit = offset + MAX_PER_PAGE + 1 limit = MAX_PER_PAGE + 1
username = get_authenticated_user().username if get_authenticated_user() else None username = get_authenticated_user().username if get_authenticated_user() else None
@ -427,27 +430,35 @@ class ConductRepositorySearch(ApiResource):
) )
) )
assert len(matching_repos) <= limit
has_additional = len(matching_repos) > MAX_PER_PAGE
matching_repos = matching_repos[0:MAX_PER_PAGE]
# Load secondary information such as last modified time, star count and action count. # Load secondary information such as last modified time, star count and action count.
repository_ids = [repo.id for repo in matching_repos] last_modified_map = None
last_modified_map = registry_model.get_most_recent_tag_lifetime_start(matching_repos) star_map = None
star_map = model.repository.get_stars(repository_ids) action_sum_map = None
action_sum_map = model.log.get_repositories_action_sums(repository_ids) if parsed_args["includeUsage"]:
repository_ids = [repo.id for repo in matching_repos]
last_modified_map = registry_model.get_most_recent_tag_lifetime_start(matching_repos)
star_map = model.repository.get_stars(repository_ids)
action_sum_map = model.log.get_repositories_action_sums(repository_ids)
# Build the results list. # Build the results list.
results = [ results = [
repo_result_view( repo_result_view(
repo, repo,
username, username,
last_modified_map.get(repo.id), last_modified_map.get(repo.id) if last_modified_map is not None else None,
star_map.get(repo.id, 0), star_map.get(repo.id, 0) if star_map is not None else None,
float(action_sum_map.get(repo.id, 0)), float(action_sum_map.get(repo.id, 0)) if action_sum_map is not None else None,
) )
for repo in matching_repos for repo in matching_repos
] ]
return { return {
"results": results[0:MAX_PER_PAGE], "results": results,
"has_additional": len(results) > MAX_PER_PAGE, "has_additional": has_additional,
"page": page, "page": page,
"page_size": MAX_PER_PAGE, "page_size": MAX_PER_PAGE,
"start_index": offset, "start_index": offset,

View File

@ -3,7 +3,7 @@ import pytest
from playhouse.test_utils import assert_query_count from playhouse.test_utils import assert_query_count
from data import model, database from data import model, database
from endpoints.api.search import ConductRepositorySearch, ConductSearch from endpoints.api.search import ConductRepositorySearch, ConductSearch, MAX_PER_PAGE
from endpoints.api.test.shared import conduct_api_call from endpoints.api.test.shared import conduct_api_call
from endpoints.test.shared import client_with_identity from endpoints.test.shared import client_with_identity
from test.fixtures import * from test.fixtures import *
@ -17,7 +17,7 @@ def test_repository_search(query, client):
with client_with_identity("devtable", client) as cl: with client_with_identity("devtable", client) as cl:
params = {"query": query} params = {"query": query}
with assert_query_count(7): with assert_query_count(4):
result = conduct_api_call(cl, ConductRepositorySearch, "GET", params, None, 200).json result = conduct_api_call(cl, ConductRepositorySearch, "GET", params, None, 200).json
assert result["start_index"] == 0 assert result["start_index"] == 0
assert result["page"] == 1 assert result["page"] == 1
@ -31,3 +31,37 @@ def test_search_query_count(query, client):
with assert_query_count(10): with assert_query_count(10):
result = conduct_api_call(cl, ConductSearch, "GET", params, None, 200).json result = conduct_api_call(cl, ConductSearch, "GET", params, None, 200).json
assert len(result["results"]) assert len(result["results"])
@pytest.mark.skipif(
os.environ.get("TEST_DATABASE_URI", "").find("mysql") >= 0,
reason="MySQL FULLTEXT indexes don't update synchronously",
)
@pytest.mark.parametrize("page_count", [1, 2, 4, 6,])
def test_repository_search_pagination(page_count, client):
# Create at least a few pages of results.
all_repositories = set()
user = model.user.get_user("devtable")
for index in range(0, MAX_PER_PAGE * page_count):
repo_name = "somerepo%s" % index
all_repositories.add(repo_name)
model.repository.create_repository("devtable", repo_name, user)
with client_with_identity("devtable", client) as cl:
for page_index in range(0, page_count):
params = {"query": "somerepo", "page": page_index + 1}
repo_results = conduct_api_call(
cl, ConductRepositorySearch, "GET", params, None, 200
).json
assert len(repo_results["results"]) <= MAX_PER_PAGE
for repo in repo_results["results"]:
all_repositories.remove(repo["name"])
if page_index < page_count - 1:
assert len(repo_results["results"]) == MAX_PER_PAGE
assert repo_results["has_additional"]
else:
assert not repo_results["has_additional"]
assert not all_repositories

View File

@ -1,41 +1,8 @@
<div class="repo-list-view-element"> <div class="repo-list-view-element">
<!-- Toggle -->
<div class="repo-list-toggleb btn-group" ng-show="!loading && optionAllowed">
<i class="btn btn-default fa fa-th-large" ng-class="!showAsList ? 'active' : ''"
ng-click="setShowAsList(false)" title="Grid View" data-container="body" bs-tooltip></i>
<i class="btn btn-default fa fa-th-list" ng-class="showAsList ? 'active' : ''"
ng-click="setShowAsList(true)" title="List View" data-container="body" bs-tooltip></i>
</div>
<div ng-transclude/> <div ng-transclude/>
<!-- Table View --> <div class="repo-list-table" repositories-resources="resources" namespaces="namespaces"
<div ng-if="showAsList"> star-toggled="starToggled({'repository': repository})"
<div class="repo-list-table" repositories-resources="resources" namespaces="namespaces" repo-kind="{{ repoKind }}">
star-toggled="starToggled({'repository': repository})"
repo-kind="{{ repoKind }}">
</div>
</div>
<!-- Grid View -->
<div ng-if="!showAsList">
<!-- Starred Repository Listing -->
<div class="repo-list-grid" repositories-resource="starredRepositories"
starred="true"
star-toggled="starToggled({'repository': repository})"
ng-if="starredRepositories"
repo-kind="{{ repoKind }}">
</div>
<!-- User and Org Repository Listings -->
<div ng-repeat="namespace in namespaces">
<div class="repo-list-grid" repositories-resource="namespace.repositories"
starred="false" namespace="namespace"
star-toggled="starToggled({'repository': repository})"
hide-title="namespaces.length == 1"
hide-namespaces="true"
repo-kind="{{ repoKind }}">
</div>
</div>
</div> </div>
</div> </div>

View File

@ -17,7 +17,7 @@ angular.module('quay').directive('repoListTable', function () {
$scope.inReadOnlyMode = StateService.inReadOnlyMode(); $scope.inReadOnlyMode = StateService.inReadOnlyMode();
$scope.repositories = null; $scope.repositories = null;
$scope.orderedRepositories = []; $scope.orderedRepositories = [];
$scope.reposPerPage = 50; $scope.reposPerPage = 25;
$scope.maxPopularity = 0; $scope.maxPopularity = 0;
$scope.options = { $scope.options = {

View File

@ -18,7 +18,6 @@ angular.module('quay').directive('repoListView', function () {
$scope.inReadOnlyMode = StateService.inReadOnlyMode(); $scope.inReadOnlyMode = StateService.inReadOnlyMode();
$scope.resources = []; $scope.resources = [];
$scope.loading = true; $scope.loading = true;
$scope.showAsList = CookieService.get('quay.repoview') == 'list';
$scope.optionAllowed = true; $scope.optionAllowed = true;
$scope.$watch('namespaces', function(namespaces) { $scope.$watch('namespaces', function(namespaces) {
@ -34,17 +33,7 @@ angular.module('quay').directive('repoListView', function () {
} }
} }
}); });
$scope.optionAllowed = $scope.resources.length <= 250;
if (!$scope.optionAllowed) {
$scope.showAsList = true;
}
}, true); }, true);
$scope.setShowAsList = function(value) {
$scope.showAsList = value;
CookieService.putPermanent('quay.repoview', value ? 'list' : 'grid');
};
} }
}; };
return directiveDefinitionObject; return directiveDefinitionObject;

View File

@ -44,7 +44,7 @@
'popularity': true 'popularity': true
}; };
$scope.organization.repositories = ApiService.listReposAsResource().withOptions(options).get(function(resp) { $scope.organization.repositories = ApiService.listReposAsResource().withPagination('repositories').withOptions(options).get(function(resp) {
return resp.repositories; return resp.repositories;
}); });
}; };

View File

@ -106,7 +106,7 @@
'public': namespace.public 'public': namespace.public
}; };
namespace.repositories = ApiService.listReposAsResource().withOptions(options).get(function(resp) { namespace.repositories = ApiService.listReposAsResource().withPagination('repositories').withOptions(options).get(function(resp) {
return resp.repositories.map(findDuplicateRepo); return resp.repositories.map(findDuplicateRepo);
}); });

View File

@ -14,7 +14,8 @@
var params = { var params = {
'query': $routeParams['q'], 'query': $routeParams['q'],
'page': $scope.currentPage 'page': $scope.currentPage,
'includeUsage': true
}; };
var MAX_PAGE_RESULTS = Config['SEARCH_MAX_RESULT_PAGE_COUNT']; var MAX_PAGE_RESULTS = Config['SEARCH_MAX_RESULT_PAGE_COUNT'];

View File

@ -59,7 +59,7 @@
'popularity': true 'popularity': true
}; };
$scope.context.viewuser.repositories = ApiService.listReposAsResource().withOptions(options).get(function(resp) { $scope.context.viewuser.repositories = ApiService.listReposAsResource().withPagination('repositories').withOptions(options).get(function(resp) {
return resp.repositories; return resp.repositories;
}); });
}; };

View File

@ -10,11 +10,18 @@ angular.module('quay').factory('ApiService', ['Restangular', '$q', 'UtilService'
var getResource = function(getMethod, operation, opt_parameters, opt_background) { var getResource = function(getMethod, operation, opt_parameters, opt_background) {
var resource = {}; var resource = {};
var paginationKey = null;
resource.withOptions = function(options) { resource.withOptions = function(options) {
this.options = options; this.options = options;
return this; return this;
}; };
resource.withPagination = function(key) {
paginationKey = key;
return this;
};
resource.get = function(processor, opt_errorHandler) { resource.get = function(processor, opt_errorHandler) {
var options = this.options; var options = this.options;
var result = { var result = {
@ -23,17 +30,44 @@ angular.module('quay').factory('ApiService', ['Restangular', '$q', 'UtilService'
'hasError': false 'hasError': false
}; };
getMethod(options, opt_parameters, opt_background, true).then(function(resp) { var paginatedResults = [];
result.value = processor(resp);
result.loading = false;
}, function(resp) {
result.hasError = true;
result.loading = false;
if (opt_errorHandler) {
opt_errorHandler(resp);
}
});
var performGet = function(opt_nextPageToken) {
if (opt_nextPageToken) {
opt_parameters = opt_parameters || {};
opt_parameters['next_page'] = opt_nextPageToken;
}
getMethod(options, opt_parameters, opt_background, true).then(function(resp) {
if (paginationKey) {
if (resp && resp[paginationKey]) {
Array.prototype.push.apply(paginatedResults, resp[paginationKey]);
var fullResp = {};
fullResp[paginationKey] = paginatedResults;
result.value = processor(fullResp);
result.loading = resp['next_page'] != null;
if (result.loading) {
performGet(resp['next_page']);
}
return;
}
}
result.value = processor(resp);
result.loading = false;
}, function(resp) {
result.hasError = true;
result.loading = false;
if (opt_errorHandler) {
opt_errorHandler(resp);
}
});
};
performGet();
return result; return result;
}; };

View File

@ -117,12 +117,10 @@ from endpoints.api.organization import (
OrganizationApplicationResetClientSecret, OrganizationApplicationResetClientSecret,
Organization, Organization,
) )
from endpoints.api.repository import ( from endpoints.api.repository import RepositoryList, RepositoryVisibility, Repository
RepositoryList,
RepositoryVisibility, from endpoints.api.repository_models_pre_oci import REPOS_PER_PAGE
Repository,
REPOS_PER_PAGE,
)
from endpoints.api.permission import ( from endpoints.api.permission import (
RepositoryUserPermission, RepositoryUserPermission,
RepositoryTeamPermission, RepositoryTeamPermission,