Commit 3ca515b2 authored by Shinya Maeda's avatar Shinya Maeda Committed by Stan Hu

Persist expanded environment name in ci build metadata

This commit persists the expanded environment name for
resolving performance concerns
parent f610a080
...@@ -59,15 +59,11 @@ module Ci ...@@ -59,15 +59,11 @@ module Ci
## ##
# Since Gitlab 11.5, deployments records started being created right after # Since Gitlab 11.5, deployments records started being created right after
# `ci_builds` creation. We can look up a relevant `environment` through # `ci_builds` creation. We can look up a relevant `environment` through
# `deployment` relation today. This is much more efficient than expanding # `deployment` relation today.
# environment name with variables.
# (See more https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/22380) # (See more https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/22380)
# #
# However, we have to still expand environment name if it's a stop action, # Since Gitlab 12.9, we started persisting the expanded environment name to
# because `deployment` persists information for start action only. # avoid repeated variables expansion in `action: stop` builds as well.
#
# We will follow up this by persisting expanded name in build metadata or
# persisting stop action in database.
def persisted_environment def persisted_environment
return unless has_environment? return unless has_environment?
...@@ -465,7 +461,14 @@ module Ci ...@@ -465,7 +461,14 @@ module Ci
return unless has_environment? return unless has_environment?
strong_memoize(:expanded_environment_name) do strong_memoize(:expanded_environment_name) do
ExpandVariables.expand(environment, -> { simple_variables }) # We're using a persisted expanded environment name in order to avoid
# variable expansion per request.
if Feature.enabled?(:ci_persisted_expanded_environment_name, project, default_enabled: true) &&
metadata&.expanded_environment_name.present?
metadata.expanded_environment_name
else
ExpandVariables.expand(environment, -> { simple_variables })
end
end end
end end
......
...@@ -14,6 +14,8 @@ module Ci ...@@ -14,6 +14,8 @@ module Ci
inverse_of: :build, inverse_of: :build,
autosave: true autosave: true
accepts_nested_attributes_for :metadata
delegate :timeout, to: :metadata, prefix: true, allow_nil: true delegate :timeout, to: :metadata, prefix: true, allow_nil: true
delegate :interruptible, to: :metadata, prefix: false, allow_nil: true delegate :interruptible, to: :metadata, prefix: false, allow_nil: true
delegate :has_exposed_artifacts?, to: :metadata, prefix: false, allow_nil: true delegate :has_exposed_artifacts?, to: :metadata, prefix: false, allow_nil: true
......
...@@ -52,7 +52,7 @@ module Ci ...@@ -52,7 +52,7 @@ module Ci
def create_build!(attributes) def create_build!(attributes)
build = project.builds.new(attributes) build = project.builds.new(attributes)
build.deployment = ::Gitlab::Ci::Pipeline::Seed::Deployment.new(build).to_resource build.assign_attributes(::Gitlab::Ci::Pipeline::Seed::Build.environment_attributes_for(build))
build.retried = false build.retried = false
build.save! build.save!
build build
......
---
title: Persist expanded environment name in ci build metadata
merge_request: 22374
author:
type: performance
# frozen_string_literal: true
class AddExpandedEnvironmentNameToCiBuildMetadata < ActiveRecord::Migration[5.2]
DOWNTIME = false
def up
add_column :ci_builds_metadata, :expanded_environment_name, :string, limit: 255
end
def down
remove_column :ci_builds_metadata, :expanded_environment_name
end
end
...@@ -722,6 +722,7 @@ ActiveRecord::Schema.define(version: 2020_02_27_165129) do ...@@ -722,6 +722,7 @@ ActiveRecord::Schema.define(version: 2020_02_27_165129) do
t.jsonb "config_variables" t.jsonb "config_variables"
t.boolean "has_exposed_artifacts" t.boolean "has_exposed_artifacts"
t.string "environment_auto_stop_in", limit: 255 t.string "environment_auto_stop_in", limit: 255
t.string "expanded_environment_name", limit: 255
t.index ["build_id"], name: "index_ci_builds_metadata_on_build_id", unique: true t.index ["build_id"], name: "index_ci_builds_metadata_on_build_id", unique: true
t.index ["build_id"], name: "index_ci_builds_metadata_on_build_id_and_has_exposed_artifacts", where: "(has_exposed_artifacts IS TRUE)" t.index ["build_id"], name: "index_ci_builds_metadata_on_build_id_and_has_exposed_artifacts", where: "(has_exposed_artifacts IS TRUE)"
t.index ["build_id"], name: "index_ci_builds_metadata_on_build_id_and_interruptible", where: "(interruptible = true)" t.index ["build_id"], name: "index_ci_builds_metadata_on_build_id_and_interruptible", where: "(interruptible = true)"
......
...@@ -7,6 +7,8 @@ module Gitlab ...@@ -7,6 +7,8 @@ module Gitlab
class Build < Seed::Base class Build < Seed::Base
include Gitlab::Utils::StrongMemoize include Gitlab::Utils::StrongMemoize
EnvironmentCreationFailure = Class.new(StandardError)
delegate :dig, to: :@seed_attributes delegate :dig, to: :@seed_attributes
# When the `ci_dag_limit_needs` is enabled it uses the lower limit # When the `ci_dag_limit_needs` is enabled it uses the lower limit
...@@ -77,14 +79,39 @@ module Gitlab ...@@ -77,14 +79,39 @@ module Gitlab
if bridge? if bridge?
::Ci::Bridge.new(attributes) ::Ci::Bridge.new(attributes)
else else
::Ci::Build.new(attributes).tap do |job| ::Ci::Build.new(attributes).tap do |build|
job.deployment = Seed::Deployment.new(job).to_resource build.assign_attributes(self.class.environment_attributes_for(build))
job.resource_group = Seed::Build::ResourceGroup.new(job, @resource_group_key).to_resource build.resource_group = Seed::Build::ResourceGroup.new(build, @resource_group_key).to_resource
end end
end end
end end
end end
def self.environment_attributes_for(build)
return {} unless build.has_environment?
environment = Seed::Environment.new(build).to_resource
# If there is a validation error on environment creation, such as
# the name contains invalid character, the build falls back to a
# non-environment job.
unless environment.persisted?
Gitlab::ErrorTracking.track_exception(
EnvironmentCreationFailure.new,
project_id: build.project_id,
reason: environment.errors.full_messages.to_sentence)
return { environment: nil }
end
{
deployment: Seed::Deployment.new(build, environment).to_resource,
metadata_attributes: {
expanded_environment_name: environment.name
}
}
end
private private
def all_of_only? def all_of_only?
......
...@@ -7,9 +7,9 @@ module Gitlab ...@@ -7,9 +7,9 @@ module Gitlab
class Deployment < Seed::Base class Deployment < Seed::Base
attr_reader :job, :environment attr_reader :job, :environment
def initialize(job) def initialize(job, environment)
@job = job @job = job
@environment = Seed::Environment.new(@job) @environment = environment
end end
def to_resource def to_resource
...@@ -17,7 +17,6 @@ module Gitlab ...@@ -17,7 +17,6 @@ module Gitlab
return unless job.starts_environment? return unless job.starts_environment?
deployment = ::Deployment.new(attributes) deployment = ::Deployment.new(attributes)
deployment.environment = environment.to_resource
# If there is a validation error on environment creation, such as # If there is a validation error on environment creation, such as
# the name contains invalid character, the job will fall back to a # the name contains invalid character, the job will fall back to a
...@@ -45,6 +44,7 @@ module Gitlab ...@@ -45,6 +44,7 @@ module Gitlab
def attributes def attributes
{ {
project: job.project, project: job.project,
environment: environment,
user: job.user, user: job.user,
ref: job.ref, ref: job.ref,
tag: job.tag, tag: job.tag,
......
...@@ -12,25 +12,15 @@ module Gitlab ...@@ -12,25 +12,15 @@ module Gitlab
end end
def to_resource def to_resource
find_environment || ::Environment.create(attributes) job.project.environments
.safe_find_or_create_by(name: expanded_environment_name)
end end
private private
def find_environment
job.project.environments.find_by_name(expanded_environment_name)
end
def expanded_environment_name def expanded_environment_name
job.expanded_environment_name job.expanded_environment_name
end end
def attributes
{
project: job.project,
name: expanded_environment_name
}
end
end end
end end
end end
......
...@@ -230,8 +230,9 @@ FactoryBot.define do ...@@ -230,8 +230,9 @@ FactoryBot.define do
# Build deployment/environment relations if environment name is set # Build deployment/environment relations if environment name is set
# to the job. If `build.deployment` has already been set, it doesn't # to the job. If `build.deployment` has already been set, it doesn't
# build a new instance. # build a new instance.
environment = Gitlab::Ci::Pipeline::Seed::Environment.new(build).to_resource
build.deployment = build.deployment =
Gitlab::Ci::Pipeline::Seed::Deployment.new(build).to_resource Gitlab::Ci::Pipeline::Seed::Deployment.new(build, environment).to_resource
end end
end end
......
...@@ -214,24 +214,98 @@ describe Gitlab::Ci::Pipeline::Seed::Build do ...@@ -214,24 +214,98 @@ describe Gitlab::Ci::Pipeline::Seed::Build do
it { is_expected.to be_a(::Ci::Build) } it { is_expected.to be_a(::Ci::Build) }
it { is_expected.to be_valid } it { is_expected.to be_valid }
context 'when job has environment name' do shared_examples_for 'deployment job' do
let(:attributes) { { name: 'rspec', ref: 'master', environment: 'production' } }
it 'returns a job with deployment' do it 'returns a job with deployment' do
expect(subject.deployment).not_to be_nil expect(subject.deployment).not_to be_nil
expect(subject.deployment.deployable).to eq(subject) expect(subject.deployment.deployable).to eq(subject)
expect(subject.deployment.environment.name).to eq('production') expect(subject.deployment.environment.name).to eq(expected_environment_name)
end end
end
shared_examples_for 'non-deployment job' do
it 'returns a job without deployment' do
expect(subject.deployment).to be_nil
end
end
shared_examples_for 'ensures environment existence' do
it 'has environment' do
expect(subject).to be_has_environment
expect(subject.environment).to eq(environment_name)
expect(subject.metadata.expanded_environment_name).to eq(expected_environment_name)
expect(Environment.exists?(name: expected_environment_name)).to eq(true)
end
end
shared_examples_for 'ensures environment inexistence' do
it 'does not have environment' do
expect(subject).not_to be_has_environment
expect(subject.environment).to be_nil
expect(subject.metadata.expanded_environment_name).to be_nil
expect(Environment.exists?(name: expected_environment_name)).to eq(false)
end
end
context 'when job deploys to production' do
let(:environment_name) { 'production' }
let(:expected_environment_name) { 'production' }
let(:attributes) { { name: 'deploy', ref: 'master', environment: 'production' } }
it_behaves_like 'deployment job'
it_behaves_like 'ensures environment existence'
context 'when the environment name is invalid' do context 'when the environment name is invalid' do
let(:attributes) { { name: 'rspec', ref: 'master', environment: '!!!' } } let(:attributes) { { name: 'deploy', ref: 'master', environment: '!!!' } }
it 'returns a job without deployment' do it_behaves_like 'non-deployment job'
expect(subject.deployment).to be_nil it_behaves_like 'ensures environment inexistence'
it 'tracks an exception' do
expect(Gitlab::ErrorTracking).to receive(:track_exception)
.with(an_instance_of(described_class::EnvironmentCreationFailure),
project_id: project.id,
reason: %q{Name can contain only letters, digits, '-', '_', '/', '$', '{', '}', '.', and spaces, but it cannot start or end with '/'})
.once
subject
end end
end end
end end
context 'when job starts a review app' do
let(:environment_name) { 'review/$CI_COMMIT_REF_NAME' }
let(:expected_environment_name) { "review/#{pipeline.ref}" }
let(:attributes) do
{
name: 'deploy', ref: 'master', environment: environment_name,
options: { environment: { name: environment_name } }
}
end
it_behaves_like 'deployment job'
it_behaves_like 'ensures environment existence'
end
context 'when job stops a review app' do
let(:environment_name) { 'review/$CI_COMMIT_REF_NAME' }
let(:expected_environment_name) { "review/#{pipeline.ref}" }
let(:attributes) do
{
name: 'deploy', ref: 'master', environment: environment_name,
options: { environment: { name: environment_name, action: 'stop' } }
}
end
it 'returns a job without deployment' do
expect(subject.deployment).to be_nil
end
it_behaves_like 'non-deployment job'
it_behaves_like 'ensures environment existence'
end
context 'when job belongs to a resource group' do context 'when job belongs to a resource group' do
let(:attributes) { { name: 'rspec', ref: 'master', resource_group_key: 'iOS' } } let(:attributes) { { name: 'rspec', ref: 'master', resource_group_key: 'iOS' } }
......
...@@ -10,7 +10,8 @@ describe Gitlab::Ci::Pipeline::Seed::Deployment do ...@@ -10,7 +10,8 @@ describe Gitlab::Ci::Pipeline::Seed::Deployment do
end end
let(:job) { build(:ci_build, project: project, pipeline: pipeline) } let(:job) { build(:ci_build, project: project, pipeline: pipeline) }
let(:seed) { described_class.new(job) } let(:environment) { Gitlab::Ci::Pipeline::Seed::Environment.new(job).to_resource }
let(:seed) { described_class.new(job, environment) }
let(:attributes) { {} } let(:attributes) { {} }
before do before do
...@@ -82,5 +83,13 @@ describe Gitlab::Ci::Pipeline::Seed::Deployment do ...@@ -82,5 +83,13 @@ describe Gitlab::Ci::Pipeline::Seed::Deployment do
is_expected.to be_nil is_expected.to be_nil
end end
end end
context 'when job does not have environment attribute' do
let(:attributes) { { name: 'test' } }
it 'returns nothing' do
is_expected.to be_nil
end
end
end end
end end
...@@ -15,29 +15,68 @@ describe Gitlab::Ci::Pipeline::Seed::Environment do ...@@ -15,29 +15,68 @@ describe Gitlab::Ci::Pipeline::Seed::Environment do
describe '#to_resource' do describe '#to_resource' do
subject { seed.to_resource } subject { seed.to_resource }
context 'when job has environment attribute' do shared_examples_for 'returning a correct environment' do
let(:attributes) do
{
environment: 'production',
options: { environment: { name: 'production' } }
}
end
it 'returns a persisted environment object' do it 'returns a persisted environment object' do
expect { subject }.to change { Environment.count }.by(1)
expect(subject).to be_a(Environment) expect(subject).to be_a(Environment)
expect(subject).to be_persisted expect(subject).to be_persisted
expect(subject.project).to eq(project) expect(subject.project).to eq(project)
expect(subject.name).to eq('production') expect(subject.name).to eq(expected_environment_name)
end end
context 'when environment has already existed' do context 'when environment has already existed' do
let!(:environment) { create(:environment, project: project, name: 'production') } let!(:environment) { create(:environment, project: project, name: expected_environment_name) }
it 'returns the existing environment object' do it 'returns the existing environment object' do
expect { subject }.not_to change { Environment.count }
expect(subject).to be_persisted expect(subject).to be_persisted
expect(subject).to eq(environment) expect(subject).to eq(environment)
end end
end end
end end
context 'when job has environment attribute' do
let(:environment_name) { 'production' }
let(:expected_environment_name) { 'production' }
let(:attributes) do
{
environment: environment_name,
options: { environment: { name: environment_name } }
}
end
it_behaves_like 'returning a correct environment'
end
context 'when job starts a review app' do
let(:environment_name) { 'review/$CI_COMMIT_REF_NAME' }
let(:expected_environment_name) { "review/#{job.ref}" }
let(:attributes) do
{
environment: environment_name,
options: { environment: { name: environment_name } }
}
end
it_behaves_like 'returning a correct environment'
end
context 'when job stops a review app' do
let(:environment_name) { 'review/$CI_COMMIT_REF_NAME' }
let(:expected_environment_name) { "review/#{job.ref}" }
let(:attributes) do
{
environment: environment_name,
options: { environment: { name: environment_name, action: 'stop' } }
}
end
it_behaves_like 'returning a correct environment'
end
end end
end end
...@@ -1293,7 +1293,35 @@ describe Ci::Build do ...@@ -1293,7 +1293,35 @@ describe Ci::Build do
environment: 'review/$APP_HOST') environment: 'review/$APP_HOST')
end end
it { is_expected.to eq('review/host') } it 'returns an expanded environment name with a list of variables' do
expect(build).to receive(:simple_variables).once.and_call_original
is_expected.to eq('review/host')
end
context 'when build metadata has already persisted the expanded environment name' do
before do
build.metadata.expanded_environment_name = 'review/host'
end
it 'returns a persisted expanded environment name without a list of variables' do
expect(build).not_to receive(:simple_variables)
is_expected.to eq('review/host')
end
context 'when ci_persisted_expanded_environment_name feature flag is disabled' do
before do
stub_feature_flags(ci_persisted_expanded_environment_name: false)
end
it 'returns an expanded environment name with a list of variables' do
expect(build).to receive(:simple_variables).once.and_call_original
is_expected.to eq('review/host')
end
end
end
end end
context 'when using persisted variables' do context 'when using persisted variables' do
......
...@@ -238,6 +238,10 @@ describe Ci::RetryBuildService do ...@@ -238,6 +238,10 @@ describe Ci::RetryBuildService do
it 'creates a new deployment' do it 'creates a new deployment' do
expect { new_build }.to change { Deployment.count }.by(1) expect { new_build }.to change { Deployment.count }.by(1)
end end
it 'persists expanded environment name' do
expect(new_build.metadata.expanded_environment_name).to eq('production')
end
end end
context 'when scheduling_type of build is nil' do context 'when scheduling_type of build is nil' do
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment