diff --git a/app/graphql/mutations/ml/models/create.rb b/app/graphql/mutations/ml/models/create.rb index 21570fc34b8f9724b4f6b6bef0bd81ee1dcbd249..c0f22fa7904ca3870b943d6a4afa21674d335a9f 100644 --- a/app/graphql/mutations/ml/models/create.rb +++ b/app/graphql/mutations/ml/models/create.rb @@ -19,12 +19,20 @@ class Create < Base def resolve(**args) project = authorized_find!(args[:project_path]) - model = ::Ml::CreateModelService.new(project, args[:name], current_user, args[:description]).execute + service_response = ::Ml::CreateModelService.new(project, args[:name], current_user, + args[:description]).execute - { - model: model.persisted? ? model : nil, - errors: errors_on_object(model) - } + if service_response.success? + { + model: service_response.payload, + errors: [] + } + else + { + model: nil, + errors: service_response.errors + } + end end end end diff --git a/app/models/ml/model.rb b/app/models/ml/model.rb index 1d5625ec8de2689d7020b0e5093dddd6da6490b0..c00c57538dacabb8d9d21a557b28db9181f022cb 100644 --- a/app/models/ml/model.rb +++ b/app/models/ml/model.rb @@ -5,6 +5,8 @@ class Model < ApplicationRecord include Presentable include Sortable + EXPERIMENT_NAME_PREFIX = '[model]' + validates :project, :default_experiment, presence: true validates :name, format: Gitlab::Regex.ml_model_name_regex, @@ -39,7 +41,7 @@ def all_packages def valid_default_experiment? return unless default_experiment - errors.add(:default_experiment) unless default_experiment.name == name + errors.add(:default_experiment) unless default_experiment.name == self.class.prefixed_experiment(name) errors.add(:default_experiment) unless default_experiment.project_id == project_id end @@ -50,5 +52,9 @@ def self.by_project_id_and_id(project_id, id) def self.by_project_id_and_name(project_id, name) find_by(project_id: project_id, name: name) end + + def self.prefixed_experiment(string) + "#{EXPERIMENT_NAME_PREFIX}#{string}" + end end end diff --git a/app/services/ml/create_experiment_service.rb b/app/services/ml/create_experiment_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..ed312b6e9650458349c597f18b264916103aff38 --- /dev/null +++ b/app/services/ml/create_experiment_service.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Ml + class CreateExperimentService + def initialize(project, experiment_name, user = nil) + @project = project + @name = experiment_name + @user = user + end + + def execute + experiment = Ml::Experiment.new(project: project, name: name, user: user) + experiment.save + + return error(experiment.errors.full_messages) unless experiment.persisted? + + success(experiment) + end + + private + + def success(model) + ServiceResponse.success(payload: model) + end + + def error(reason) + ServiceResponse.error(message: reason) + end + + attr_reader :project, :name, :user + end +end diff --git a/app/services/ml/create_model_service.rb b/app/services/ml/create_model_service.rb index 7ac9c2a27371b42118e13dc00f6db6887fe6a560..46035194a12f5b4dfa1894bad66141c4dfbfb13e 100644 --- a/app/services/ml/create_model_service.rb +++ b/app/services/ml/create_model_service.rb @@ -12,34 +12,42 @@ def initialize(project, name, user = nil, description = nil, metadata = []) def execute ApplicationRecord.transaction do + experiment_result = Ml::CreateExperimentService.new(@project, experiment_name, @user).execute + + next experiment_result if experiment_result.error? + model = Ml::Model.new( project: @project, name: @name, user: @user, description: @description, - default_experiment: default_experiment + default_experiment: experiment_result.payload ) model.save - if model.persisted? - add_metadata(model, @metadata) + next error(model.errors.full_messages) unless model.persisted? + + Gitlab::InternalEvents.track_event( + 'model_registry_ml_model_created', + project: @project, + user: @user + ) - Gitlab::InternalEvents.track_event( - 'model_registry_ml_model_created', - project: @project, - user: @user - ) - end + add_metadata(model, @metadata) - model + success(model) end end private - def default_experiment - @default_experiment ||= Ml::FindOrCreateExperimentService.new(@project, @name).execute + def success(model) + ServiceResponse.success(payload: model) + end + + def error(reason) + ServiceResponse.error(message: reason) end def add_metadata(model, metadata_key_value) @@ -57,5 +65,9 @@ def add_metadata(model, metadata_key_value) ::Ml::ModelMetadata.create!(entry) end end + + def experiment_name + Ml::Model.prefixed_experiment(@name) + end end end diff --git a/app/services/ml/find_or_create_experiment_service.rb b/app/services/ml/find_or_create_experiment_service.rb deleted file mode 100644 index 1fe10c7f8562e3f44372c34ae3538341759e69ff..0000000000000000000000000000000000000000 --- a/app/services/ml/find_or_create_experiment_service.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -module Ml - class FindOrCreateExperimentService - def initialize(project, experiment_name, user = nil) - @project = project - @name = experiment_name - @user = user - end - - def execute - Ml::Experiment.find_or_create(project, name, user) - end - - private - - attr_reader :project, :name, :user - end -end diff --git a/app/services/ml/find_or_create_model_service.rb b/app/services/ml/find_or_create_model_service.rb deleted file mode 100644 index 9199730e84bfc0ec85f364b1e4918ee6b5255dff..0000000000000000000000000000000000000000 --- a/app/services/ml/find_or_create_model_service.rb +++ /dev/null @@ -1,18 +0,0 @@ -# frozen_string_literal: true - -module Ml - class FindOrCreateModelService - def initialize(project, name, user = nil, description = nil, metadata = []) - @project = project - @name = name - @description = description - @metadata = metadata - @user = user - end - - def execute - FindModelService.new(@project, @name).execute || - CreateModelService.new(@project, @name, @user, @description, @metadata).execute - end - end -end diff --git a/doc/user/project/ml/experiment_tracking/mlflow_client.md b/doc/user/project/ml/experiment_tracking/mlflow_client.md index d52e2c2fae17446a3a31c046bda9a1b9a9450020..0fc139cfc23a67c7a88bd1f915a36ecb99656fad 100644 --- a/doc/user/project/ml/experiment_tracking/mlflow_client.md +++ b/doc/user/project/ml/experiment_tracking/mlflow_client.md @@ -128,13 +128,13 @@ client.delete_registered_model(model_name) ### Logging candidates to a model -Every model has an associated experiment with the same name. To log a candidate/run to the model, -use the experiment with the name of the model: +Every model has an associated experiment with the same name prefixed by `[model]`. +To log a candidate/run to the model, use the experiment passing the correct name: ```python client = MlflowClient() model_name = '<your_model_name>' -exp = client.get_experiment_by_name(model_name) +exp = client.get_experiment_by_name(f"[model]{model_name}") run = client.create_run(exp.experiment_id) ``` diff --git a/lib/api/ml/mlflow/registered_models.rb b/lib/api/ml/mlflow/registered_models.rb index 3f4996a94c07f6c8b05e4922d63c215fb7e89c39..8a9434d24471b4d509e92590609a8f8e6ad20968 100644 --- a/lib/api/ml/mlflow/registered_models.rb +++ b/lib/api/ml/mlflow/registered_models.rb @@ -31,7 +31,7 @@ class RegisteredModels < ::API::Base optional :tags, type: Array, desc: 'Additional metadata for registered model.' end post 'create', urgency: :low do - model = ::Ml::CreateModelService.new( + service_result = ::Ml::CreateModelService.new( user_project, params[:name], current_user, @@ -39,9 +39,9 @@ class RegisteredModels < ::API::Base params[:tags] ).execute - resource_already_exists! unless model.persisted? + resource_already_exists! unless service_result.success? - present model, + present service_result.payload, with: Entities::Ml::Mlflow::RegisteredModel, root: :registered_model rescue ActiveRecord::RecordInvalid diff --git a/spec/factories/ml/models.rb b/spec/factories/ml/models.rb index ae00ade9054957164f1c2935c5a7425716c65726..cf7322bd62a20622d683f5b44ed0c56b72489092 100644 --- a/spec/factories/ml/models.rb +++ b/spec/factories/ml/models.rb @@ -5,7 +5,7 @@ sequence(:name) { |n| "model#{n}" } project - default_experiment { association :ml_experiments, project_id: project.id, name: name } + default_experiment { association :ml_experiments, project_id: project.id, name: "[model]#{name}" } trait :with_versions do versions { Array.new(2) { association(:ml_model_versions, model: instance) } } diff --git a/spec/models/ml/model_spec.rb b/spec/models/ml/model_spec.rb index e1de44b00304c9e48b7bc83585c538376c5b0790..38d44938b7428ea407f62f47355084002f195a2f 100644 --- a/spec/models/ml/model_spec.rb +++ b/spec/models/ml/model_spec.rb @@ -8,7 +8,7 @@ let_it_be(:existing_model) { create(:ml_models, name: 'an_existing_model', project: project1) } let_it_be(:another_existing_model) { create(:ml_models, name: 'an_existing_model', project: project2) } let_it_be(:valid_name) { 'a_valid_name' } - let_it_be(:default_experiment) { create(:ml_experiments, name: valid_name, project: project1) } + let_it_be(:default_experiment) { create(:ml_experiments, name: "[model]#{valid_name}", project: project1) } describe 'associations' do it { is_expected.to belong_to(:project) } @@ -164,4 +164,10 @@ expect(all_packages).to match_array([version1.package, version2.package]) end end + + describe '.prefixed_experiment' do + it 'returns the given string prefixed with "[model]"' do + expect(described_class.prefixed_experiment('a_valid_name')).to eq('[model]a_valid_name') + end + end end diff --git a/spec/requests/api/graphql/mutations/ml/models/create_spec.rb b/spec/requests/api/graphql/mutations/ml/models/create_spec.rb index 0daabeab0d18035e29e80db01e13a0315010ff30..959e67d63fb5582a3012d38aa66286d329cd7b8a 100644 --- a/spec/requests/api/graphql/mutations/ml/models/create_spec.rb +++ b/spec/requests/api/graphql/mutations/ml/models/create_spec.rb @@ -28,7 +28,7 @@ end context 'when user is allowed write changes' do - it 'creates a models' do + it 'creates a model' do post_graphql_mutation(mutation, current_user: current_user) expect(response).to have_gitlab_http_status(:success) @@ -39,7 +39,7 @@ end context 'when name already exists' do - err_msg = "Name has already been taken" + err_msg = "Name should be unique in the project" let(:name) { model.name } it_behaves_like 'a mutation that returns errors in the response', errors: [err_msg] diff --git a/spec/services/ml/create_experiment_service_spec.rb b/spec/services/ml/create_experiment_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..5fdca79cb33dda90174be194343a3c05ab2ce34f --- /dev/null +++ b/spec/services/ml/create_experiment_service_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ::Ml::CreateExperimentService, feature_category: :mlops do + let_it_be(:project) { create(:project) } + let_it_be(:user) { project.first_owner } + let_it_be(:existing_experiment) { create(:ml_experiments, project: project, user: user) } + + let(:name) { 'new_experiment' } + + subject(:create_experiment) { described_class.new(project, name, user).execute } + + describe '#execute' do + subject(:created_experiment) { create_experiment.payload } + + it 'creates an experiment', :aggregate_failures do + expect(create_experiment).to be_success + expect(created_experiment.name).to eq('new_experiment') + expect(created_experiment.project).to eq(project) + expect(created_experiment.user).to eq(user) + end + + context 'when experiment already exists' do + let(:name) { existing_experiment.name } + + it 'returns an error', :aggregate_failures do + expect { create_experiment }.not_to change { Ml::Experiment.count } + + expect(create_experiment).to be_error + end + end + end +end diff --git a/spec/services/ml/create_model_service_spec.rb b/spec/services/ml/create_model_service_spec.rb index 88e7c00d1f9668f48c9ef9726abab1136eeac5aa..dd4d41b49955e7465e4098227325aac15d917373 100644 --- a/spec/services/ml/create_model_service_spec.rb +++ b/spec/services/ml/create_model_service_spec.rb @@ -16,6 +16,20 @@ subject(:create_model) { described_class.new(project, name, user, description, metadata).execute } describe '#execute' do + subject(:model_payload) { create_model.payload } + + context 'when model name is not supplied' do + let(:name) { nil } + let(:project) { existing_model.project } + + it 'returns a model with errors', :aggregate_failures do + expect { create_model }.not_to change { Ml::Model.count } + expect(create_model).to be_error + expect(Gitlab::InternalEvents).not_to have_received(:track_event) + expect(create_model.message).to include("Name can't be blank") + end + end + context 'when model name does not exist in the project' do let(:name) { 'new_model' } let(:project) { existing_model.project } @@ -27,7 +41,8 @@ { project: project, user: user } ) - expect(create_model.name).to eq(name) + expect(model_payload.name).to eq('new_model') + expect(model_payload.default_experiment.name).to eq('[model]new_model') end end @@ -42,7 +57,7 @@ { project: project, user: user } ) - expect(create_model.name).to eq(name) + expect(model_payload.name).to eq(name) end end @@ -51,9 +66,10 @@ let(:project) { existing_model.project } it 'returns a model with errors', :aggregate_failures do - expect(create_model).not_to be_persisted + expect { create_model }.not_to change { Ml::Model.count } + expect(create_model).to be_error expect(Gitlab::InternalEvents).not_to have_received(:track_event) - expect(create_model.errors.full_messages).to eq(["Name has already been taken"]) + expect(create_model.message).to eq(["Name should be unique in the project"]) end end @@ -65,8 +81,8 @@ it 'creates metadata records', :aggregate_failures do expect { create_model }.to change { Ml::Model.count }.by(1) - expect(create_model.name).to eq(name) - expect(create_model.metadata.count).to be 2 + expect(model_payload.name).to eq(name) + expect(model_payload.metadata.count).to be 2 end end diff --git a/spec/services/ml/find_or_create_experiment_service_spec.rb b/spec/services/ml/find_or_create_experiment_service_spec.rb deleted file mode 100644 index a8c533d13203532180cd16e564f1230a759f3580..0000000000000000000000000000000000000000 --- a/spec/services/ml/find_or_create_experiment_service_spec.rb +++ /dev/null @@ -1,33 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe ::Ml::FindOrCreateExperimentService, feature_category: :mlops do - let_it_be(:project) { create(:project) } - let_it_be(:user) { project.first_owner } - let_it_be(:existing_experiment) { create(:ml_experiments, project: project, user: user) } - - let(:name) { 'new_experiment' } - - subject(:new_experiment) { described_class.new(project, name, user).execute } - - describe '#execute' do - it 'creates an experiment using Ml::Experiment.find_or_create', :aggregate_failures do - expect(Ml::Experiment).to receive(:find_or_create).and_call_original - - expect(new_experiment.name).to eq('new_experiment') - expect(new_experiment.project).to eq(project) - expect(new_experiment.user).to eq(user) - end - - context 'when experiment already exists' do - let(:name) { existing_experiment.name } - - it 'fetches existing experiment', :aggregate_failures do - expect { new_experiment }.not_to change { Ml::Experiment.count } - - expect(new_experiment).to eq(existing_experiment) - end - end - end -end diff --git a/spec/services/ml/find_or_create_model_service_spec.rb b/spec/services/ml/find_or_create_model_service_spec.rb deleted file mode 100644 index 5d5eaea0a72d2358562e968edcecd4ebd999ea87..0000000000000000000000000000000000000000 --- a/spec/services/ml/find_or_create_model_service_spec.rb +++ /dev/null @@ -1,48 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe ::Ml::FindOrCreateModelService, feature_category: :mlops do - let_it_be(:user) { create(:user) } - let_it_be(:existing_model) { create(:ml_models) } - let_it_be(:another_project) { create(:project) } - let_it_be(:description) { 'description' } - let_it_be(:metadata) { [] } - - subject(:create_model) { described_class.new(project, name, user, description, metadata).execute } - - describe '#execute' do - context 'when model name does not exist in the project' do - let(:name) { 'new_model' } - let(:project) { existing_model.project } - - it 'creates a model', :aggregate_failures do - expect { create_model }.to change { Ml::Model.count }.by(1) - - expect(create_model.name).to eq(name) - end - end - - context 'when model name exists but project is different' do - let(:name) { existing_model.name } - let(:project) { another_project } - - it 'creates a model', :aggregate_failures do - expect { create_model }.to change { Ml::Model.count }.by(1) - - expect(create_model.name).to eq(name) - end - end - - context 'when model with name exists' do - let(:name) { existing_model.name } - let(:project) { existing_model.project } - - it 'fetches existing model', :aggregate_failures do - expect { create_model }.to change { Ml::Model.count }.by(0) - - expect(create_model).to eq(existing_model) - end - end - end -end