Skip to content
代码片段 群组 项目
提交 28df9bdf 编辑于 作者: Eduardo Bonet's avatar Eduardo Bonet 提交者: Vasilii Iakliushin
浏览文件

Allows for fetching candidate data as csv

Adds a new format to ::Projects::Ml::ExperimentsController.show that
allows downloading the data as a csv file.

Changelog: added
上级 2c25f3a4
No related branches found
No related tags found
无相关合并请求
...@@ -27,13 +27,22 @@ def show ...@@ -27,13 +27,22 @@ def show
.transform_keys(&:underscore) .transform_keys(&:underscore)
.permit(:name, :order_by, :sort, :order_by_type) .permit(:name, :order_by, :sort, :order_by_type)
paginator = CandidateFinder finder = CandidateFinder.new(@experiment, find_params)
.new(@experiment, find_params)
.execute
.keyset_paginate(cursor: params[:cursor], per_page: MAX_CANDIDATES_PER_PAGE)
@candidates = paginator.records respond_to do |format|
@page_info = page_info(paginator) format.csv do
csv_data = ::Ml::CandidatesCsvPresenter.new(finder.execute).present
send_data(csv_data, type: 'text/csv; charset=utf-8', filename: 'candidates.csv')
end
format.html do
paginator = finder.execute.keyset_paginate(cursor: params[:cursor], per_page: MAX_CANDIDATES_PER_PAGE)
@candidates = paginator.records
@page_info = page_info(paginator)
end
end
end end
def destroy def destroy
......
...@@ -28,7 +28,7 @@ class Candidate < ApplicationRecord ...@@ -28,7 +28,7 @@ class Candidate < ApplicationRecord
scope: :project, scope: :project,
init: AtomicInternalId.project_init(self, :internal_id) init: AtomicInternalId.project_init(self, :internal_id)
scope :including_relationships, -> { includes(:latest_metrics, :params, :user, :package) } scope :including_relationships, -> { includes(:latest_metrics, :params, :user, :package, :project) }
scope :by_name, ->(name) { where("ml_candidates.name LIKE ?", "%#{sanitize_sql_like(name)}%") } # rubocop:disable GitlabSecurity/SqlInjection scope :by_name, ->(name) { where("ml_candidates.name LIKE ?", "%#{sanitize_sql_like(name)}%") } # rubocop:disable GitlabSecurity/SqlInjection
scope :order_by_metric, ->(metric, direction) do scope :order_by_metric, ->(metric, direction) do
......
# frozen_string_literal: true
module Ml
class CandidatesCsvPresenter
CANDIDATE_ASSOCIATIONS = [:latest_metrics, :params, :experiment].freeze
# This file size limit is mainly to avoid the generation to hog resources from the server. The value is arbitrary
# can be update once we have better insight into usage.
TARGET_FILESIZE = 2.megabytes
def initialize(candidates)
@candidates = candidates
end
def present
CsvBuilder.new(@candidates, headers, CANDIDATE_ASSOCIATIONS).render(TARGET_FILESIZE)
end
private
def headers
metric_names = columns_names(&:metrics)
param_names = columns_names(&:params)
candidate_to_metrics = @candidates.to_h do |candidate|
[candidate.id, candidate.latest_metrics.to_h { |m| [m.name, m.value] }]
end
candidate_to_params = @candidates.to_h do |candidate|
[candidate.id, candidate.params.to_h { |m| [m.name, m.value] }]
end
{
project_id: 'project_id',
experiment_iid: ->(c) { c.experiment.iid },
candidate_iid: 'internal_id',
name: 'name',
external_id: 'eid',
start_time: 'start_time',
end_time: 'end_time',
**param_names.index_with { |name| ->(c) { candidate_to_params.dig(c.id, name) } },
**metric_names.index_with { |name| ->(c) { candidate_to_metrics.dig(c.id, name) } }
}
end
def columns_names(&selector)
@candidates.flat_map(&selector).map(&:name).uniq
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe ::Ml::CandidatesCsvPresenter, feature_category: :mlops do
# rubocop:disable RSpec/FactoryBot/AvoidCreate
let_it_be(:project) { create(:project, :repository) }
let_it_be(:experiment) { create(:ml_experiments, user_id: project.creator, project: project) }
let_it_be(:candidate0) do
create(:ml_candidates, experiment: experiment, user: project.creator,
project: project, start_time: 1234, end_time: 5678).tap do |c|
c.params.create!([{ name: 'param1', value: 'p1' }, { name: 'param2', value: 'p2' }])
c.metrics.create!(
[{ name: 'metric1', value: 0.1 }, { name: 'metric2', value: 0.2 }, { name: 'metric3', value: 0.3 }]
)
end
end
let_it_be(:candidate1) do
create(:ml_candidates, experiment: experiment, user: project.creator, name: 'candidate1',
project: project, start_time: 1111, end_time: 2222).tap do |c|
c.params.create([{ name: 'param2', value: 'p3' }, { name: 'param3', value: 'p4' }])
c.metrics.create!(name: 'metric3', value: 0.4)
end
end
# rubocop:enable RSpec/FactoryBot/AvoidCreate
describe '.present' do
subject { described_class.new(::Ml::Candidate.where(id: [candidate0.id, candidate1.id])).present }
it 'generates header row correctly' do
expected_header = %w[project_id experiment_iid candidate_iid name external_id start_time end_time param1 param2
param3 metric1 metric2 metric3].join(',')
header = subject.split("\n")[0]
expect(header).to eq(expected_header)
end
it 'generates the first row correctly' do
expected_row = [
candidate0.project_id,
1, # experiment.iid
1, # candidate0.internal_id
'', # candidate0 has no name, column is empty
candidate0.eid,
candidate0.start_time,
candidate0.end_time,
candidate0.params[0].value,
candidate0.params[1].value,
'', # candidate0 has no param3, column is empty
candidate0.metrics[0].value,
candidate0.metrics[1].value,
candidate0.metrics[2].value
].map(&:to_s)
row = subject.split("\n")[1].split(",")
expect(row).to match_array(expected_row)
end
it 'generates the second row correctly' do
expected_row = [
candidate1.project_id,
1, # experiment.iid
2, # candidate1.internal_id
'candidate1',
candidate1.eid,
candidate1.start_time,
candidate1.end_time,
'', # candidate1 has no param1, column is empty
candidate1.params[0].value,
candidate1.params[1].value,
'', # candidate1 has no metric1, column is empty
'', # candidate1 has no metric2, column is empty
candidate1.metrics[0].value
].map(&:to_s)
row = subject.split("\n")[2].split(",")
expect(row).to match_array(expected_row)
end
end
end
...@@ -122,109 +122,143 @@ ...@@ -122,109 +122,143 @@
end end
describe 'GET show' do describe 'GET show' do
it 'renders the template' do describe 'html' do
show_experiment it 'renders the template' do
show_experiment
expect(response).to render_template('projects/ml/experiments/show')
end
describe 'pagination' do expect(response).to render_template('projects/ml/experiments/show')
let_it_be(:candidates) do
create_list(:ml_candidates, 5, experiment: experiment).tap do |c|
c.first.metrics.create!(name: 'metric1', value: 0.3)
c[1].metrics.create!(name: 'metric1', value: 0.2)
c.last.metrics.create!(name: 'metric1', value: 0.6)
end
end end
let(:params) { basic_params.merge(id: experiment.iid) } describe 'pagination' do
let_it_be(:candidates) do
create_list(:ml_candidates, 5, experiment: experiment).tap do |c|
c.first.metrics.create!(name: 'metric1', value: 0.3)
c[1].metrics.create!(name: 'metric1', value: 0.2)
c.last.metrics.create!(name: 'metric1', value: 0.6)
end
end
before do let(:params) { basic_params.merge(id: experiment.iid) }
stub_const("Projects::Ml::ExperimentsController::MAX_CANDIDATES_PER_PAGE", 2)
show_experiment before do
end stub_const("Projects::Ml::ExperimentsController::MAX_CANDIDATES_PER_PAGE", 2)
it 'fetches only MAX_CANDIDATES_PER_PAGE candidates' do show_experiment
expect(assigns(:candidates).size).to eq(2) end
end
it 'paginates' do it 'fetches only MAX_CANDIDATES_PER_PAGE candidates' do
received = assigns(:page_info) expect(assigns(:candidates).size).to eq(2)
end
expect(received).to include({ it 'paginates' do
has_next_page: true, received = assigns(:page_info)
has_previous_page: false,
start_cursor: nil
})
end
context 'when order by metric' do expect(received).to include({
let(:params) do has_next_page: true,
{ has_previous_page: false,
order_by: "metric1", start_cursor: nil
order_by_type: "metric", })
sort: "desc"
}
end end
it 'paginates', :aggregate_failures do context 'when order by metric' do
page = assigns(:candidates) let(:params) do
{
order_by: "metric1",
order_by_type: "metric",
sort: "desc"
}
end
expect(page.first).to eq(candidates.last) it 'paginates', :aggregate_failures do
expect(page.last).to eq(candidates.first) page = assigns(:candidates)
new_params = params.merge(cursor: assigns(:page_info)[:end_cursor]) expect(page.first).to eq(candidates.last)
expect(page.last).to eq(candidates.first)
show_experiment(new_params) new_params = params.merge(cursor: assigns(:page_info)[:end_cursor])
new_page = assigns(:candidates) show_experiment(new_params: new_params)
expect(new_page.first).to eq(candidates[1]) new_page = assigns(:candidates)
expect(new_page.first).to eq(candidates[1])
end
end end
end end
end
describe 'search' do describe 'search' do
let(:params) do let(:params) do
basic_params.merge( basic_params.merge(
name: 'some_name', name: 'some_name',
orderBy: 'name', orderBy: 'name',
orderByType: 'metric', orderByType: 'metric',
sort: 'asc', sort: 'asc',
invalid: 'invalid' invalid: 'invalid'
) )
end
it 'formats and filters the parameters' do
expect(Projects::Ml::CandidateFinder).to receive(:new).and_call_original do |exp, params|
expect(params.to_h).to include({
name: 'some_name',
order_by: 'name',
order_by_type: 'metric',
sort: 'asc'
})
end
show_experiment
end
end end
it 'formats and filters the parameters' do it 'does not perform N+1 sql queries' do
expect(Projects::Ml::CandidateFinder).to receive(:new).and_call_original do |exp, params| control_count = ActiveRecord::QueryRecorder.new(skip_cached: false) { show_experiment }
expect(params.to_h).to include({
name: 'some_name', create_list(:ml_candidates, 2, :with_metrics_and_params, experiment: experiment)
order_by: 'name',
order_by_type: 'metric', expect { show_experiment }.not_to exceed_all_query_limit(control_count)
sort: 'asc' end
})
describe '404' do
before do
show_experiment
end end
show_experiment it_behaves_like '404 if experiment does not exist'
it_behaves_like '404 if feature flag disabled'
end end
end end
it 'does not perform N+1 sql queries' do describe 'csv' do
control_count = ActiveRecord::QueryRecorder.new(skip_cached: false) { show_experiment } it 'responds with :ok', :aggregate_failures do
show_experiment_csv
create_list(:ml_candidates, 2, :with_metrics_and_params, experiment: experiment) expect(response).to have_gitlab_http_status(:ok)
expect(response.headers['Content-Type']).to eq('text/csv; charset=utf-8')
end
expect { show_experiment }.not_to exceed_all_query_limit(control_count) it 'calls the presenter' do
end allow(::Ml::CandidatesCsvPresenter).to receive(:new).and_call_original
describe '404' do show_experiment_csv
before do end
show_experiment
it 'does not perform N+1 sql queries' do
control_count = ActiveRecord::QueryRecorder.new(skip_cached: false) { show_experiment_csv }
create_list(:ml_candidates, 2, :with_metrics_and_params, experiment: experiment)
expect { show_experiment_csv }.not_to exceed_all_query_limit(control_count)
end end
it_behaves_like '404 if experiment does not exist' describe '404' do
it_behaves_like '404 if feature flag disabled' before do
show_experiment_csv
end
it_behaves_like '404 if experiment does not exist'
it_behaves_like '404 if feature flag disabled'
end
end end
end end
...@@ -253,8 +287,12 @@ ...@@ -253,8 +287,12 @@
private private
def show_experiment(new_params = nil) def show_experiment(new_params: nil, format: :html)
get project_ml_experiment_path(project, experiment_iid), params: new_params || params get project_ml_experiment_path(project, experiment_iid, format: format), params: new_params || params
end
def show_experiment_csv
show_experiment(format: :csv)
end end
def list_experiments(new_params = nil) def list_experiments(new_params = nil)
......
0% 加载中 .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册