From f079f01829f4f7b887d35375a5ce2a90afb98a15 Mon Sep 17 00:00:00 2001 From: Eduardo Bonet <ebonet@gitlab.com> Date: Mon, 24 Jul 2023 07:25:42 +0000 Subject: [PATCH] Adds services to create Ml::* entities Adds: - Ml::FindOrCreateModelService - Ml::FindOrCreateExperimentService - Ml::FindOrCreateModelVersionService Changelog: added --- app/models/ml/experiment.rb | 4 ++ app/models/ml/model.rb | 5 ++ app/models/ml/model_version.rb | 6 +++ .../ml/find_or_create_experiment_service.rb | 19 +++++++ .../ml/find_or_create_model_service.rb | 22 ++++++++ .../find_or_create_model_version_service.rb | 22 ++++++++ spec/models/ml/experiment_spec.rb | 39 ++++++++++++++ spec/models/ml/model_spec.rb | 54 +++++++++++++++++-- spec/models/ml/model_version_spec.rb | 32 ++++++++++- .../find_or_create_experiment_service_spec.rb | 33 ++++++++++++ .../ml/find_or_create_model_service_spec.rb | 45 ++++++++++++++++ ...nd_or_create_model_version_service_spec.rb | 49 +++++++++++++++++ 12 files changed, 324 insertions(+), 6 deletions(-) create mode 100644 app/services/ml/find_or_create_experiment_service.rb create mode 100644 app/services/ml/find_or_create_model_service.rb create mode 100644 app/services/ml/find_or_create_model_version_service.rb create mode 100644 spec/services/ml/find_or_create_experiment_service_spec.rb create mode 100644 spec/services/ml/find_or_create_model_service_spec.rb create mode 100644 spec/services/ml/find_or_create_model_version_service_spec.rb diff --git a/app/models/ml/experiment.rb b/app/models/ml/experiment.rb index 5c5f8d3b2db71..ad6c6b7b3bf00 100644 --- a/app/models/ml/experiment.rb +++ b/app/models/ml/experiment.rb @@ -59,6 +59,10 @@ def package_for_experiment?(package_name) numeric?(iid) end + def find_or_create(project, name, user) + create_with(user: user).find_or_create_by(project: project, name: name) + end + private def numeric?(value) diff --git a/app/models/ml/model.rb b/app/models/ml/model.rb index 684b8e1983b70..f901eb69be4cb 100644 --- a/app/models/ml/model.rb +++ b/app/models/ml/model.rb @@ -21,5 +21,10 @@ def valid_default_experiment? errors.add(:default_experiment) unless default_experiment.name == name errors.add(:default_experiment) unless default_experiment.project_id == project_id end + + def self.find_or_create(project, name, experiment) + create_with(default_experiment: experiment) + .find_or_create_by(project: project, name: name) + end end end diff --git a/app/models/ml/model_version.rb b/app/models/ml/model_version.rb index 540fe6018a102..3450dc64b79f3 100644 --- a/app/models/ml/model_version.rb +++ b/app/models/ml/model_version.rb @@ -18,6 +18,12 @@ class ModelVersion < ApplicationRecord delegate :name, to: :model + class << self + def find_or_create(model, version, package) + create_with(package: package).find_or_create_by(project: model.project, model: model, version: version) + end + end + private def valid_model? diff --git a/app/services/ml/find_or_create_experiment_service.rb b/app/services/ml/find_or_create_experiment_service.rb new file mode 100644 index 0000000000000..1fe10c7f8562e --- /dev/null +++ b/app/services/ml/find_or_create_experiment_service.rb @@ -0,0 +1,19 @@ +# 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 new file mode 100644 index 0000000000000..66dec7a6234e7 --- /dev/null +++ b/app/services/ml/find_or_create_model_service.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Ml + class FindOrCreateModelService + def initialize(project, model_name) + @project = project + @name = model_name + end + + def execute + Ml::Model.find_or_create( + project, + name, + Ml::FindOrCreateExperimentService.new(project, name).execute + ) + end + + private + + attr_reader :name, :project + end +end diff --git a/app/services/ml/find_or_create_model_version_service.rb b/app/services/ml/find_or_create_model_version_service.rb new file mode 100644 index 0000000000000..558374ad57bd0 --- /dev/null +++ b/app/services/ml/find_or_create_model_version_service.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Ml + class FindOrCreateModelVersionService + def initialize(project, params = {}) + @project = project + @name = params[:model_name] + @version = params[:version] + @package = params[:package] + end + + def execute + model = Ml::FindOrCreateModelService.new(project, name).execute + + Ml::ModelVersion.find_or_create(model, version, package) + end + + private + + attr_reader :version, :name, :project, :package + end +end diff --git a/spec/models/ml/experiment_spec.rb b/spec/models/ml/experiment_spec.rb index 1ee35d6da03a3..36bdb61183367 100644 --- a/spec/models/ml/experiment_spec.rb +++ b/spec/models/ml/experiment_spec.rb @@ -79,6 +79,45 @@ end end + describe '.find_or_create' do + let(:name) { exp.name } + let(:project) { exp.project } + + subject(:find_or_create) { described_class.find_or_create(project, name, exp.user) } + + context 'when experiments exists' do + it 'fetches existing experiment', :aggregate_failures do + expect { find_or_create }.not_to change { Ml::Experiment.count } + + expect(find_or_create).to eq(exp) + end + end + + context 'when experiments does not exist' do + let(:name) { 'a new experiment' } + + it 'creates the experiment', :aggregate_failures do + expect { find_or_create }.to change { Ml::Experiment.count }.by(1) + + expect(find_or_create.name).to eq(name) + expect(find_or_create.user).to eq(exp.user) + expect(find_or_create.project).to eq(project) + end + end + + context 'when experiment name exists but project is different' do + let(:project) { create(:project) } + + it 'creates a model', :aggregate_failures do + expect { find_or_create }.to change { Ml::Experiment.count }.by(1) + + expect(find_or_create.name).to eq(name) + expect(find_or_create.user).to eq(exp.user) + expect(find_or_create.project).to eq(project) + end + end + end + describe '#with_candidate_count' do let_it_be(:exp3) do create(:ml_experiments, project: exp.project).tap do |e| diff --git a/spec/models/ml/model_spec.rb b/spec/models/ml/model_spec.rb index 397ea23dd8554..cfe39239e8c14 100644 --- a/spec/models/ml/model_spec.rb +++ b/spec/models/ml/model_spec.rb @@ -3,6 +3,13 @@ require 'spec_helper' RSpec.describe Ml::Model, feature_category: :mlops do + let_it_be(:base_project) { create(:project) } + let_it_be(:existing_model) { create(:ml_models, name: 'an_existing_model', project: base_project) } + let_it_be(:valid_name) { 'a_valid_name' } + let_it_be(:default_experiment) { create(:ml_experiments, name: valid_name, project: base_project) } + + let(:project) { base_project } + describe 'associations' do it { is_expected.to belong_to(:project) } it { is_expected.to have_one(:default_experiment) } @@ -12,11 +19,6 @@ describe '#valid?' do using RSpec::Parameterized::TableSyntax - let_it_be(:project) { create(:project) } - let_it_be(:existing_model) { create(:ml_models, name: 'an_existing_model', project: project) } - let_it_be(:valid_name) { 'a_valid_name' } - let_it_be(:default_experiment) { create(:ml_experiments, name: valid_name, project: project) } - let(:name) { valid_name } subject(:errors) do @@ -59,4 +61,46 @@ end end end + + describe '.find_or_create' do + subject(:find_or_create) { described_class.find_or_create(project, name, experiment) } + + let(:name) { existing_model.name } + let(:project) { existing_model.project } + let(:experiment) { default_experiment } + + context 'when model name does not exist in the project' do + let(:name) { 'new_model' } + let(:experiment) { build(:ml_experiments, name: name, project: project) } + + it 'creates a model', :aggregate_failures do + expect { find_or_create }.to change { Ml::Model.count }.by(1) + + expect(find_or_create.name).to eq(name) + expect(find_or_create.project).to eq(project) + expect(find_or_create.default_experiment).to eq(experiment) + end + end + + context 'when model name exists but project is different' do + let(:project) { create(:project) } + let(:experiment) { build(:ml_experiments, name: name, project: project) } + + it 'creates a model', :aggregate_failures do + expect { find_or_create }.to change { Ml::Model.count }.by(1) + + expect(find_or_create.name).to eq(name) + expect(find_or_create.project).to eq(project) + expect(find_or_create.default_experiment).to eq(experiment) + end + end + + context 'when model exists' do + it 'fetches existing model', :aggregate_failures do + expect { find_or_create }.not_to change { Ml::Model.count } + + expect(find_or_create).to eq(existing_model) + end + end + end end diff --git a/spec/models/ml/model_version_spec.rb b/spec/models/ml/model_version_spec.rb index ef53a1ac3a0ed..9af9a2a56c6c7 100644 --- a/spec/models/ml/model_version_spec.rb +++ b/spec/models/ml/model_version_spec.rb @@ -6,6 +6,7 @@ using RSpec::Parameterized::TableSyntax let_it_be(:base_project) { create(:project) } + let_it_be(:model) { create(:ml_models, project: base_project) } describe 'associations' do it { is_expected.to belong_to(:project) } @@ -15,7 +16,6 @@ describe 'validation' do let_it_be(:valid_version) { 'valid_version' } - let_it_be(:model) { create(:ml_models, project: base_project) } let_it_be(:valid_package) do build_stubbed(:ml_model_package, project: base_project, version: valid_version, name: model.name) end @@ -87,4 +87,34 @@ end end end + + describe '.find_or_create' do + let_it_be(:existing_model_version) { create(:ml_model_versions, model: model, version: 'abc') } + + let(:version) { existing_model_version.version } + let(:package) { nil } + + subject(:find_or_create) { described_class.find_or_create(model, version, package) } + + context 'if model version exists' do + it 'returns the model version', :aggregate_failures do + expect { find_or_create }.not_to change { Ml::ModelVersion.count } + is_expected.to eq(existing_model_version) + end + end + + context 'if model version does not exist' do + let(:version) { 'new_version' } + let(:package) { create(:ml_model_package, project: model.project, name: model.name, version: version) } + + it 'creates another model version', :aggregate_failures do + expect { find_or_create }.to change { Ml::ModelVersion.count }.by(1) + model_version = find_or_create + + expect(model_version.version).to eq(version) + expect(model_version.model).to eq(model) + expect(model_version.package).to eq(package) + end + end + 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 new file mode 100644 index 0000000000000..a8c533d132035 --- /dev/null +++ b/spec/services/ml/find_or_create_experiment_service_spec.rb @@ -0,0 +1,33 @@ +# 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 new file mode 100644 index 0000000000000..6ddae20f8d633 --- /dev/null +++ b/spec/services/ml/find_or_create_model_service_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ::Ml::FindOrCreateModelService, feature_category: :mlops do + let_it_be(:existing_model) { create(:ml_models) } + let_it_be(:another_project) { create(:project) } + + subject(:create_model) { described_class.new(project, name).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 diff --git a/spec/services/ml/find_or_create_model_version_service_spec.rb b/spec/services/ml/find_or_create_model_version_service_spec.rb new file mode 100644 index 0000000000000..5c29a3430d820 --- /dev/null +++ b/spec/services/ml/find_or_create_model_version_service_spec.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ::Ml::FindOrCreateModelVersionService, feature_category: :mlops do + let_it_be(:existing_version) { create(:ml_model_versions) } + let_it_be(:another_project) { create(:project) } + + let(:package) { nil } + + let(:params) do + { + model_name: name, + version: version, + package: package + } + end + + subject(:model_version) { described_class.new(project, params).execute } + + describe '#execute' do + context 'when model version exists' do + let(:name) { existing_version.name } + let(:version) { existing_version.version } + let(:project) { existing_version.project } + + it 'returns existing model version', :aggregate_failures do + expect { model_version }.to change { Ml::ModelVersion.count }.by(0) + expect(model_version).to eq(existing_version) + end + end + + context 'when model version does not exist' do + let(:project) { existing_version.project } + let(:name) { 'a_new_model' } + let(:version) { 'a_new_version' } + + let(:package) { create(:ml_model_package, project: project, name: name, version: version) } + + it 'creates a new model version', :aggregate_failures do + expect { model_version }.to change { Ml::ModelVersion.count } + + expect(model_version.name).to eq(name) + expect(model_version.version).to eq(version) + expect(model_version.package).to eq(package) + end + end + end +end -- GitLab