1
0
mirror of https://github.com/quay/quay.git synced 2025-07-30 07:43:13 +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 {}
assert len(repository_ids) > 0 and None not in repository_ids
assert len(repository_ids) <= 100
query = (
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:
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(
unfiltered_query, filter_user.id, limit, repo_kind
unfiltered_query, filter_user.id, offset + limit, repo_kind
)
if offset > 0:
take(offset, iterator)
@ -429,7 +431,7 @@ def _get_sorted_matching_repositories(
Repository.select(*select_fields)
.join(RepositorySearchScore)
.where(Repository.state != RepositoryState.MARKED_FOR_DELETION)
.order_by(RepositorySearchScore.score.desc())
.order_by(RepositorySearchScore.score.desc(), RepositorySearchScore.id)
)
else:
if search_fields is None:
@ -454,7 +456,7 @@ def _get_sorted_matching_repositories(
.join(RepositorySearchScore)
.where(clause)
.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:

View File

@ -198,8 +198,9 @@ class RegistryDataInterface(object):
@abstractmethod
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
each repository in the given repository reference list.
Returns a map from repository ID to the last modified time (seconds from epoch, UTC) for
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

View File

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

View File

@ -91,6 +91,7 @@ class PreOCIModel(RepositoryDataInterface):
popularity,
):
next_page_token = None
# Lookup the requested repositories (either starred or non-starred.)
if starred:
# 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
)
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:
# 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
@ -128,13 +118,17 @@ class PreOCIModel(RepositoryDataInterface):
start_id=start_id,
limit=REPOS_PER_PAGE + 1,
kind_filter=repo_kind,
namespace=namespace,
)
repos, next_page_token = model.modelutil.paginate_query(
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.
last_modified_map = {}
action_sum_map = {}

View File

@ -408,6 +408,9 @@ class ConductRepositorySearch(ApiResource):
@parse_args()
@query_param("query", "The search query.", type=str, default="")
@query_param("page", "The page.", type=int, default=1)
@query_param(
"includeUsage", "Whether to include usage metadata", type=truthy_bool, default=False
)
@nickname("conductRepoSearch")
def get(self, parsed_args):
"""
@ -416,7 +419,7 @@ class ConductRepositorySearch(ApiResource):
query = parsed_args["query"]
page = min(max(1, parsed_args["page"]), MAX_RESULT_PAGE_COUNT)
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
@ -427,7 +430,15 @@ 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.
last_modified_map = None
star_map = None
action_sum_map = None
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)
@ -438,16 +449,16 @@ class ConductRepositorySearch(ApiResource):
repo_result_view(
repo,
username,
last_modified_map.get(repo.id),
star_map.get(repo.id, 0),
float(action_sum_map.get(repo.id, 0)),
last_modified_map.get(repo.id) if last_modified_map is not None else None,
star_map.get(repo.id, 0) if star_map is not None else None,
float(action_sum_map.get(repo.id, 0)) if action_sum_map is not None else None,
)
for repo in matching_repos
]
return {
"results": results[0:MAX_PER_PAGE],
"has_additional": len(results) > MAX_PER_PAGE,
"results": results,
"has_additional": has_additional,
"page": page,
"page_size": MAX_PER_PAGE,
"start_index": offset,

View File

@ -3,7 +3,7 @@ import pytest
from playhouse.test_utils import assert_query_count
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.test.shared import client_with_identity
from test.fixtures import *
@ -17,7 +17,7 @@ def test_repository_search(query, client):
with client_with_identity("devtable", client) as cl:
params = {"query": query}
with assert_query_count(7):
with assert_query_count(4):
result = conduct_api_call(cl, ConductRepositorySearch, "GET", params, None, 200).json
assert result["start_index"] == 0
assert result["page"] == 1
@ -31,3 +31,37 @@ def test_search_query_count(query, client):
with assert_query_count(10):
result = conduct_api_call(cl, ConductSearch, "GET", params, None, 200).json
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">
<!-- 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/>
<!-- Table View -->
<div ng-if="showAsList">
<div class="repo-list-table" repositories-resources="resources" namespaces="namespaces"
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>

View File

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

View File

@ -18,7 +18,6 @@ angular.module('quay').directive('repoListView', function () {
$scope.inReadOnlyMode = StateService.inReadOnlyMode();
$scope.resources = [];
$scope.loading = true;
$scope.showAsList = CookieService.get('quay.repoview') == 'list';
$scope.optionAllowed = true;
$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);
$scope.setShowAsList = function(value) {
$scope.showAsList = value;
CookieService.putPermanent('quay.repoview', value ? 'list' : 'grid');
};
}
};
return directiveDefinitionObject;

View File

@ -44,7 +44,7 @@
'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;
});
};

View File

@ -106,7 +106,7 @@
'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);
});

View File

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

View File

@ -59,7 +59,7 @@
'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;
});
};

View File

@ -10,11 +10,18 @@ angular.module('quay').factory('ApiService', ['Restangular', '$q', 'UtilService'
var getResource = function(getMethod, operation, opt_parameters, opt_background) {
var resource = {};
var paginationKey = null;
resource.withOptions = function(options) {
this.options = options;
return this;
};
resource.withPagination = function(key) {
paginationKey = key;
return this;
};
resource.get = function(processor, opt_errorHandler) {
var options = this.options;
var result = {
@ -23,7 +30,32 @@ angular.module('quay').factory('ApiService', ['Restangular', '$q', 'UtilService'
'hasError': false
};
var paginatedResults = [];
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) {
@ -33,7 +65,9 @@ angular.module('quay').factory('ApiService', ['Restangular', '$q', 'UtilService'
opt_errorHandler(resp);
}
});
};
performGet();
return result;
};

View File

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