Search groups
Task 3: Search Groups Using Elasticsearch
Context
Replace the current database query (Api.groups(search)) with Elasticsearch queries to improve performance. This requires creating a query builder, search service, and updating the API/frontend.
Implementation Details
1. Create Group Query Builder
File: ee/lib/search/elastic/group_query_builder.rb
Follow pattern from work_item_query_builder.rb:
module Search
module Elastic
class GroupQueryBuilder
def initialize(search_term, current_user, params = {})
@search_term = search_term
@current_user = current_user
@params = params
end
def build
{
query: query_hash,
sort: sort_order,
size: @params[:per_page] || 20
}
end
private
def query_hash
{
bool: {
must: must_clauses,
filter: filter_clauses
}
}
end
def must_clauses
return [] if @search_term.blank?
[{
multi_match: {
query: @search_term,
fields: ['name^3', 'full_name^2', 'path^2', 'description'],
type: 'best_fields',
fuzziness: 'AUTO'
}
}]
end
def filter_clauses
[
visibility_filter,
traversal_filter
].compact
end
def visibility_filter
# Filter based on user permissions
# Public groups: visibility_level = 20
# Internal groups: visibility_level = 10 (if user logged in)
# Private groups: only if user has access
end
def traversal_filter
# Filter by parent if specified
return unless @params[:parent_id]
{ term: { parent_id: @params[:parent_id] } }
end
def sort_order
case @params[:order_by]
when 'name'
[{ 'name.keyword' => { order: 'asc' } }]
when 'similarity'
['_score']
else
[{ updated_at: { order: 'desc' } }]
end
end
end
end
end2. Create Group Search Results Class
File: ee/lib/search/elastic/group_search_results.rb
module Search
module Elastic
class GroupSearchResults
attr_reader :current_user, :query, :params
def initialize(current_user, query, params = {})
@current_user = current_user
@query = query
@params = params
end
def objects(scope = nil)
return Group.none unless use_elasticsearch?
# Build ES query
es_query = GroupQueryBuilder.new(query, current_user, params).build
# Execute search
response = client.search(
index: index_name,
body: es_query
)
# Hydrate results from database
group_ids = response.dig('hits', 'hits')&.map { |hit| hit['_source']['id'] } || []
Group.id_in(group_ids).preload(:route)
end
def formatted_count
objects.count
end
private
def use_elasticsearch?
::Feature.enabled?(:elasticsearch_group_search, current_user) &&
::Gitlab::CurrentSettings.elasticsearch_search?
end
def index_name
Search::Elastic::References::Group.index
end
def client
Group.__elasticsearch__.client
end
end
end
end3. Update Groups API
File: lib/api/groups.rb (or create ee/lib/api/ee/groups.rb override)
Modify the groups search endpoint:
get do
if use_elasticsearch_for_groups?
results = ::Search::Elastic::GroupSearchResults.new(
current_user,
params[:search],
params
).objects
present paginate(results), with: Entities::Group
else
# Existing database search
groups = GroupsFinder.new(current_user, params).execute
present paginate(groups), with: Entities::Group
end
end
helpers do
def use_elasticsearch_for_groups?
::Feature.enabled?(:elasticsearch_group_search, current_user) &&
::Gitlab::CurrentSettings.elasticsearch_search?
end
end4. Update Frontend API Client
File: app/assets/javascripts/api/groups_api.js
The existing Api.groups() call should work without changes if the backend API is updated. The frontend just calls the API endpoint.
5. Add Feature Flag
File: config/feature_flags/development/elasticsearch_group_search.yml
---
name: elasticsearch_group_search
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/XXXXXX
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/XXXXXX
milestone: 'X.Y'
type: development
group: group::global search
default_enabled: false6. Visibility and Permissions
The query builder must respect:
- Public groups (visibility_level: 20) - visible to everyone
- Internal groups (visibility_level: 10) - visible to logged-in users
- Private groups (visibility_level: 0) - visible only to members
- Use
traversal_idsfor hierarchy-based filtering
Files to Create/Modify
ee/lib/search/elastic/group_query_builder.rb(new)ee/lib/search/elastic/group_search_results.rb(new)lib/api/groups.rboree/lib/api/ee/groups.rb(modify)config/feature_flags/development/elasticsearch_group_search.yml(new)
Testing
ee/spec/lib/search/elastic/group_query_builder_spec.rb- Test text search on name, path, description
- Test visibility filtering
- Test parent/hierarchy filtering
- Test sort orders
ee/spec/lib/search/elastic/group_search_results_spec.rb- Test result hydration
- Test empty results
- Test permission filtering
ee/spec/requests/api/groups_spec.rb(modify)- Test ES-powered search
- Test fallback to database search
- Test feature flag toggle
Validation Steps
-
Enable feature flag:
Feature.enable(:elasticsearch_group_search) -
Test search via console:
results = Search::Elastic::GroupSearchResults.new( User.first, 'gitlab', {} ).objects -
Test via API:
curl -H "Authorization: Bearer TOKEN" \ "http://localhost:3000/api/v4/groups?search=gitlab" -
Verify ES query:
# Enable ES query logging curl -s "localhost:9200/gitlab-development-groups/_search?pretty" \ -d '{ "query": { "multi_match": { "query": "gitlab", "fields": ["name^3", "full_name^2"] }}}'
Performance Testing
- Benchmark database query vs ES query
- Test with 10k, 100k, 1M groups
- Measure response time improvement
- Test concurrent searches
Acceptance Criteria
- Group query builder constructs proper ES queries
- Search results class executes ES searches and hydrates AR objects
- API endpoint uses ES when feature flag enabled
- Search respects visibility levels and user permissions
- Feature flag controls ES vs DB search
- Fuzzy matching works correctly
- Sort options (similarity, name, date) work
- Performance improvement measured and documented
- Fallback to database search when ES unavailable