From 46f46999286537c807fffb8d1a07a6ae47992712 Mon Sep 17 00:00:00 2001 From: Shinya Maeda Date: Wed, 20 Nov 2019 17:27:27 +0700 Subject: [PATCH 1/8] PoC Pipeline job sempahore --- app/models/ci/build.rb | 10 ++++ app/models/ci/job_lock.rb | 46 +++++++++++++++++++ app/models/ci/project_semaphore.rb | 15 ++++++ app/models/project.rb | 1 + app/services/ci/process_build_service.rb | 4 +- .../20191120184725_ci_project_semaphores.rb | 24 ++++++++++ db/schema.rb | 24 +++++++++- lib/gitlab/ci/status/blocked_semaphore.rb | 33 +++++++++++++ 8 files changed, 155 insertions(+), 2 deletions(-) create mode 100644 app/models/ci/job_lock.rb create mode 100644 app/models/ci/project_semaphore.rb create mode 100644 db/migrate/20191120184725_ci_project_semaphores.rb create mode 100644 lib/gitlab/ci/status/blocked_semaphore.rb diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 59bff4e2d2bad6..0f435748e19359 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -40,6 +40,7 @@ class Build < CommitStatus }.freeze has_one :deployment, as: :deployable, class_name: 'Deployment' + has_one :job_lock, class_name: 'Ci::JobLock', foreign_key: :job_id has_many :trace_sections, class_name: 'Ci::BuildTraceSection' has_many :trace_chunks, class_name: 'Ci::BuildTraceChunk', foreign_key: :build_id @@ -60,6 +61,7 @@ class Build < CommitStatus delegate :terminal_specification, to: :runner_session, allow_nil: true delegate :gitlab_deploy_token, to: :project delegate :trigger_short_token, to: :trigger_request, allow_nil: true + delegate :try_lock, to: :job_lock ## # Since Gitlab 11.5, deployments records started being created right after @@ -347,6 +349,10 @@ def schedulable? self.when == 'delayed' && options[:start_in].present? end + def lockable? + job_lock.present? + end + def options_scheduled_at ChronicDuration.parse(options[:start_in])&.seconds&.from_now end @@ -420,6 +426,10 @@ def has_environment? environment.present? end + def has_lock? + options[:lock].present? + end + def starts_environment? has_environment? && self.environment_action == 'start' end diff --git a/app/models/ci/job_lock.rb b/app/models/ci/job_lock.rb new file mode 100644 index 00000000000000..d21ae5c18f37e2 --- /dev/null +++ b/app/models/ci/job_lock.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Ci + class JobLock < ApplicationRecord + self.table_name = 'ci_job_locks' + + belongs_to :ci_semaphore, class_name: 'Ci::ProjectSemaphore', inverse_of: :ci_semaphores + belongs_to :job, class_name: 'Ci::Build', inverse_of: :job_lock + + delegate :under_limit?, to: :ci_semaphore + + state_machine :status, initial: :created do + event :try_lock do + transition created: :locking, if: :under_limit? + transition created: :blocked, unless: :under_limit? + end + + event :lock do + transition created: :locking + end + + event :block do + transition created: :blocked + end + + event :release do + transition any - [:released] => :released + end + + before_transition blocked: :locking do |job_lock| + build.blocked_duration = Time.now - job_lock.updated_at + end + + after_transition created: :locking do |job_lock| + job_lock.job.enqueue + end + end + + enum :status { + created: 0, + locking: 1, + blocked: 2, + released: 3, + } + end +end diff --git a/app/models/ci/project_semaphore.rb b/app/models/ci/project_semaphore.rb new file mode 100644 index 00000000000000..8a3fc9e83137f0 --- /dev/null +++ b/app/models/ci/project_semaphore.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Ci + class ProjectSemaphore < ApplicationRecord + self.table_name = 'ci_project_semaphores' + + belongs_to :project, inverse_of: :ci_semaphores + + has_many :job_locks, class_name: 'Ci::JobLock', foreign_key: :semaphore_id + + def under_limit? + job_locks.locking.count < concurrency + end + end +end diff --git a/app/models/project.rb b/app/models/project.rb index 9a208b0058b04d..7122b74c782399 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -281,6 +281,7 @@ class Project < ApplicationRecord has_many :builds, class_name: 'Ci::Build', inverse_of: :project, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :build_trace_section_names, class_name: 'Ci::BuildTraceSectionName' has_many :build_trace_chunks, class_name: 'Ci::BuildTraceChunk', through: :builds, source: :trace_chunks + has_many :ci_semaphores, class_name: 'Ci::ProjectSemaphore' has_many :job_artifacts, class_name: 'Ci::JobArtifact' has_many :runner_projects, class_name: 'Ci::RunnerProject', inverse_of: :project has_many :runners, through: :runner_projects, source: :runner, class_name: 'Ci::Runner' diff --git a/app/services/ci/process_build_service.rb b/app/services/ci/process_build_service.rb index eb92c7d1a271dc..f5a00ea63ba9d8 100644 --- a/app/services/ci/process_build_service.rb +++ b/app/services/ci/process_build_service.rb @@ -4,7 +4,9 @@ module Ci class ProcessBuildService < BaseService def execute(build, current_status) if valid_statuses_for_when(build.when).include?(current_status) - if build.schedulable? + if build.lockable? + build.try_lock + elsif build.schedulable? build.schedule elsif build.action? build.actionize diff --git a/db/migrate/20191120184725_ci_project_semaphores.rb b/db/migrate/20191120184725_ci_project_semaphores.rb new file mode 100644 index 00000000000000..738f8abeea3ce6 --- /dev/null +++ b/db/migrate/20191120184725_ci_project_semaphores.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class CiProjectSemaphores < ActiveRecord::Migration[5.2] + DOWNTIME = false + + def change + create_table :ci_project_semaphores do |t| + t.references :project, null: false, index: false, foreign_key: { on_delete: :cascade } + t.string :semaphore, null: false + t.integer :concurrency, null: false, default: 1 + t.index %i[project_id semaphore], unique: true + t.timestamps_with_timezone + end + + create_table :ci_job_locks do |t| + t.references :semaphore, null: false, index: false, foreign_key: { to_table: :ci_project_semaphores, on_delete: :cascade } + t.references :job, null: false, index: false, foreign_key: { to_table: :ci_builds, on_delete: :cascade } + t.integer :status, limit: 2, null: false + t.integer :blocked_duration + t.index %i[semaphore_id job_id], unique: true + t.timestamps_with_timezone + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 21d045be380c2e..0681184805c28b 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2019_11_18_182722) do +ActiveRecord::Schema.define(version: 2019_11_20_184725) do # These are extensions that must be enabled in order to support this database enable_extension "pg_trgm" @@ -755,6 +755,16 @@ t.index ["project_id"], name: "index_ci_job_artifacts_on_project_id_for_security_reports", where: "(file_type = ANY (ARRAY[5, 6, 7, 8]))" end + create_table "ci_job_locks", force: :cascade do |t| + t.bigint "semaphore_id", null: false + t.bigint "job_id", null: false + t.integer "status", limit: 2, null: false + t.integer "blocked_duration" + t.datetime_with_timezone "created_at", null: false + t.datetime_with_timezone "updated_at", null: false + t.index ["semaphore_id", "job_id"], name: "index_ci_job_locks_on_semaphore_id_and_job_id", unique: true + end + create_table "ci_job_variables", force: :cascade do |t| t.string "key", null: false t.text "encrypted_value" @@ -855,6 +865,15 @@ t.index ["user_id"], name: "index_ci_pipelines_on_user_id" end + create_table "ci_project_semaphores", force: :cascade do |t| + t.bigint "project_id", null: false + t.string "semaphore", null: false + t.integer "concurrency", default: 1, null: false + t.datetime_with_timezone "created_at", null: false + t.datetime_with_timezone "updated_at", null: false + t.index ["project_id", "semaphore"], name: "index_ci_project_semaphores_on_project_id_and_semaphore", unique: true + end + create_table "ci_runner_namespaces", id: :serial, force: :cascade do |t| t.integer "runner_id" t.integer "namespace_id" @@ -4278,6 +4297,8 @@ add_foreign_key "ci_group_variables", "namespaces", column: "group_id", name: "fk_33ae4d58d8", on_delete: :cascade add_foreign_key "ci_job_artifacts", "ci_builds", column: "job_id", on_delete: :cascade add_foreign_key "ci_job_artifacts", "projects", on_delete: :cascade + add_foreign_key "ci_job_locks", "ci_builds", column: "job_id", on_delete: :cascade + add_foreign_key "ci_job_locks", "ci_project_semaphores", column: "semaphore_id", on_delete: :cascade add_foreign_key "ci_job_variables", "ci_builds", column: "job_id", on_delete: :cascade add_foreign_key "ci_pipeline_chat_data", "chat_names", on_delete: :cascade add_foreign_key "ci_pipeline_chat_data", "ci_pipelines", column: "pipeline_id", on_delete: :cascade @@ -4290,6 +4311,7 @@ add_foreign_key "ci_pipelines", "external_pull_requests", name: "fk_190998ef09", on_delete: :nullify add_foreign_key "ci_pipelines", "merge_requests", name: "fk_a23be95014", on_delete: :cascade add_foreign_key "ci_pipelines", "projects", name: "fk_86635dbd80", on_delete: :cascade + add_foreign_key "ci_project_semaphores", "projects", on_delete: :cascade add_foreign_key "ci_runner_namespaces", "ci_runners", column: "runner_id", on_delete: :cascade add_foreign_key "ci_runner_namespaces", "namespaces", on_delete: :cascade add_foreign_key "ci_runner_projects", "projects", name: "fk_4478a6f1e4", on_delete: :cascade diff --git a/lib/gitlab/ci/status/blocked_semaphore.rb b/lib/gitlab/ci/status/blocked_semaphore.rb new file mode 100644 index 00000000000000..13ea615f4242bc --- /dev/null +++ b/lib/gitlab/ci/status/blocked_semaphore.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + module Status + ## + # Extended status used when pipeline or stage passed conditionally. + # This means that failed jobs that are allowed to fail were present. + # + class BlockedSemaphore < Status::Extended + def text + s_('CiStatusText|passed') + end + + def label + s_('CiStatusLabel|passed with warnings') + end + + def icon + 'status_warning' + end + + def group + 'success-with-warnings' + end + + def self.matches?(subject, user) + subject.created? && subject.job_lock&.blocked? + end + end + end + end +end -- GitLab From cd53727d130b23e8bc4742bacf37c09fb3ff0d96 Mon Sep 17 00:00:00 2001 From: Shinya Maeda Date: Thu, 21 Nov 2019 13:37:24 +0700 Subject: [PATCH 2/8] Yaml parser --- app/models/ci/build.rb | 4 ++++ app/models/ci/job_lock.rb | 2 +- lib/gitlab/ci/config/entry/job.rb | 9 +++++--- lib/gitlab/ci/yaml_processor.rb | 3 ++- .../ci/create_pipeline_service_spec.rb | 23 +++++++++++++++++++ 5 files changed, 36 insertions(+), 5 deletions(-) diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 0f435748e19359..0bf761dc6b0ff1 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -430,6 +430,10 @@ def has_lock? options[:lock].present? end + def lock_value + options.fetch(:lock, nil) + end + def starts_environment? has_environment? && self.environment_action == 'start' end diff --git a/app/models/ci/job_lock.rb b/app/models/ci/job_lock.rb index d21ae5c18f37e2..0c1c74d55398b2 100644 --- a/app/models/ci/job_lock.rb +++ b/app/models/ci/job_lock.rb @@ -36,7 +36,7 @@ class JobLock < ApplicationRecord end end - enum :status { + enum status: { created: 0, locking: 1, blocked: 2, diff --git a/lib/gitlab/ci/config/entry/job.rb b/lib/gitlab/ci/config/entry/job.rb index c75ae87a98555b..7674479e710b56 100644 --- a/lib/gitlab/ci/config/entry/job.rb +++ b/lib/gitlab/ci/config/entry/job.rb @@ -16,7 +16,8 @@ class Job < ::Gitlab::Config::Entry::Node ALLOWED_KEYS = %i[tags script only except rules type image services allow_failure type stage when start_in artifacts cache dependencies before_script needs after_script variables - environment coverage retry parallel extends interruptible timeout].freeze + environment coverage retry parallel extends interruptible timeout + lock].freeze REQUIRED_BY_NEEDS = %i[stage].freeze @@ -51,6 +52,7 @@ class Job < ::Gitlab::Config::Entry::Node validates :dependencies, array_of_strings: true validates :extends, array_of_strings_or_string: true validates :rules, array_of_hashes: true + validates :lock, type: String end validates :start_in, duration: { limit: '1 day' }, if: :delayed? @@ -151,7 +153,7 @@ class Job < ::Gitlab::Config::Entry::Node attributes :script, :tags, :allow_failure, :when, :dependencies, :needs, :retry, :parallel, :extends, :start_in, :rules, - :interruptible, :timeout + :interruptible, :timeout, :lock def self.matching?(name, config) !name.to_s.start_with?('.') && @@ -231,7 +233,8 @@ def to_hash artifacts: artifacts_value, after_script: after_script_value, ignore: ignored?, - needs: needs_defined? ? needs_value : nil } + needs: needs_defined? ? needs_value : nil, + lock: lock } end end end diff --git a/lib/gitlab/ci/yaml_processor.rb b/lib/gitlab/ci/yaml_processor.rb index 833c545fc5b131..8a167bf30c0c73 100644 --- a/lib/gitlab/ci/yaml_processor.rb +++ b/lib/gitlab/ci/yaml_processor.rb @@ -59,7 +59,8 @@ def build_attributes(name) instance: job[:instance], start_in: job[:start_in], trigger: job[:trigger], - bridge_needs: job.dig(:needs, :bridge)&.first + bridge_needs: job.dig(:needs, :bridge)&.first, + lock: job[:lock], }.compact }.compact end diff --git a/spec/services/ci/create_pipeline_service_spec.rb b/spec/services/ci/create_pipeline_service_spec.rb index de0f4841215240..fb242299508de4 100644 --- a/spec/services/ci/create_pipeline_service_spec.rb +++ b/spec/services/ci/create_pipeline_service_spec.rb @@ -885,6 +885,29 @@ def previous_commit_sha_from_ref(ref) end end + context 'with lock' do + before do + config = YAML.dump( + deploy: { + script: 'ls', + environment: { name: 'review/$CI_COMMIT_REF_NAME' }, + lock: '$CI_ENVIRONMENT_NAME' + } + ) + + stub_ci_pipeline_yaml_file(config) + end + + it 'persists lock attribute to build option' do + result = execute_service + job = result.builds.find_by_name(:deploy) + + expect(result).to be_persisted + expect(job).to be_has_lock + expect(job.lock_value).to eq('$CI_ENVIRONMENT_NAME') + end + end + shared_examples 'when ref is protected' do let(:user) { create(:user) } -- GitLab From 5d57c5c50cec7d0e9eac3bbd99602e63bee45da8 Mon Sep 17 00:00:00 2001 From: Shinya Maeda Date: Thu, 21 Nov 2019 15:11:17 +0700 Subject: [PATCH 3/8] Add some tests --- app/models/ci/job_lock.rb | 11 +- app/models/ci/project_semaphore.rb | 2 + lib/gitlab/ci/pipeline/seed/build.rb | 1 + lib/gitlab/ci/pipeline/seed/build/lock.rb | 47 +++++ .../ci/create_pipeline_service_spec.rb | 168 ++++++++++++++++-- 5 files changed, 207 insertions(+), 22 deletions(-) create mode 100644 lib/gitlab/ci/pipeline/seed/build/lock.rb diff --git a/app/models/ci/job_lock.rb b/app/models/ci/job_lock.rb index 0c1c74d55398b2..f2e36e346ab407 100644 --- a/app/models/ci/job_lock.rb +++ b/app/models/ci/job_lock.rb @@ -4,17 +4,12 @@ module Ci class JobLock < ApplicationRecord self.table_name = 'ci_job_locks' - belongs_to :ci_semaphore, class_name: 'Ci::ProjectSemaphore', inverse_of: :ci_semaphores + belongs_to :ci_semaphore, class_name: 'Ci::ProjectSemaphore', foreign_key: :semaphore_id, inverse_of: :job_locks belongs_to :job, class_name: 'Ci::Build', inverse_of: :job_lock delegate :under_limit?, to: :ci_semaphore state_machine :status, initial: :created do - event :try_lock do - transition created: :locking, if: :under_limit? - transition created: :blocked, unless: :under_limit? - end - event :lock do transition created: :locking end @@ -42,5 +37,9 @@ class JobLock < ApplicationRecord blocked: 2, released: 3, } + + def try_lock + under_limit? ? lock : block + end end end diff --git a/app/models/ci/project_semaphore.rb b/app/models/ci/project_semaphore.rb index 8a3fc9e83137f0..3dd5a6bcfbe3a7 100644 --- a/app/models/ci/project_semaphore.rb +++ b/app/models/ci/project_semaphore.rb @@ -8,6 +8,8 @@ class ProjectSemaphore < ApplicationRecord has_many :job_locks, class_name: 'Ci::JobLock', foreign_key: :semaphore_id + validates :semaphore, presence: true + def under_limit? job_locks.locking.count < concurrency end diff --git a/lib/gitlab/ci/pipeline/seed/build.rb b/lib/gitlab/ci/pipeline/seed/build.rb index dce56b22666498..6fc8235a91a39d 100644 --- a/lib/gitlab/ci/pipeline/seed/build.rb +++ b/lib/gitlab/ci/pipeline/seed/build.rb @@ -78,6 +78,7 @@ def to_resource else ::Ci::Build.new(attributes).tap do |job| job.deployment = Seed::Deployment.new(job).to_resource + job.job_lock = Seed::Build::Lock.new(job).to_resource end end end diff --git a/lib/gitlab/ci/pipeline/seed/build/lock.rb b/lib/gitlab/ci/pipeline/seed/build/lock.rb new file mode 100644 index 00000000000000..966d82bbfbbdc8 --- /dev/null +++ b/lib/gitlab/ci/pipeline/seed/build/lock.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + module Pipeline + module Seed + class Build + class Lock < Seed::Base + attr_reader :job + + def initialize(job) + @job = job + end + + def to_resource + return unless job.has_lock? + + semaphore = find_or_create_semaphore + + unless semaphore + # TODO: Gitlab::Sentry.track_exception or invalid parameters + raise ArgumentError + return + end + + semaphore.job_locks.build(job: job) + end + + private + + def find_or_create_semaphore + # TODO: Gitlab::OptimisticLocking to avoid race condition + ::Ci::ProjectSemaphore.find_or_create_by(attributes_for_semaphore) + end + + def attributes_for_semaphore + { + project: job.project, + semaphore: job.expanded_lock_value + } + end + end + end + end + end + end +end diff --git a/spec/services/ci/create_pipeline_service_spec.rb b/spec/services/ci/create_pipeline_service_spec.rb index fb242299508de4..b4b77b22dc9dbc 100644 --- a/spec/services/ci/create_pipeline_service_spec.rb +++ b/spec/services/ci/create_pipeline_service_spec.rb @@ -885,26 +885,162 @@ def previous_commit_sha_from_ref(ref) end end - context 'with lock' do - before do - config = YAML.dump( - deploy: { - script: 'ls', - environment: { name: 'review/$CI_COMMIT_REF_NAME' }, - lock: '$CI_ENVIRONMENT_NAME' - } - ) + context 'with lock', :sidekiq_inline do + context 'when Limit per job' do + before do + config = YAML.dump( + test: { stage: 'test', script: 'ls' }, + deploy: { stage: 'deploy', script: 'ls', lock: 'tmp-key' } + ) - stub_ci_pipeline_yaml_file(config) + stub_ci_pipeline_yaml_file(config) + end + + it 'has the deploy job with locking status' do + result = execute_service + test_job = result.builds.find_by_name!(:test) + deploy_job = result.builds.find_by_name!(:deploy) + + expect(deploy_job).to be_created + expect(deploy_job.job_lock).to be_created + + # when test job finished + test_job.success! + deploy_job.reload + + expect(deploy_job).to be_pending + expect(deploy_job.job_lock).to be_locking + end + + context 'when the other job has already obtained the lock' do + before do + job = create(:ci_build) + ci_semaphore = project.ci_semaphores.create!(semaphore: 'deploy') + ci_semaphore.job_locks.create!(job: job) + end + + it 'has the deploy job with blocked status' do + result = execute_service + deploy_job = result.builds.find_by_name!(:deploy) + + expect(deploy_job).to be_created + expect(deploy_job.job_lock).to be_created + + # when test job finished + test_job.success! + deploy_job.reload + + expect(deploy_job).to be_created + expect(deploy_job.job_lock).to be_blocked + end + end end - it 'persists lock attribute to build option' do - result = execute_service - job = result.builds.find_by_name(:deploy) + context 'when Limit per job' do + before do + config = YAML.dump( + test: { + script: 'ls' + }, + deploy: { + script: 'ls', + environment: { name: 'prd' }, + lock: '$CI_JOB_NAME' + } + ) - expect(result).to be_persisted - expect(job).to be_has_lock - expect(job.lock_value).to eq('$CI_ENVIRONMENT_NAME') + stub_ci_pipeline_yaml_file(config) + end + + it 'persists the association correctly' do + result = execute_service + test_job = result.builds.find_by_name!(:test) + deploy_job = result.builds.find_by_name!(:deploy) + project_semaphore = project.ci_semaphores.find_by_semaphore!('deploy') + + expect(result).to be_persisted + expect(test_job).not_to be_has_lock + expect(deploy_job).to be_has_lock + expect(deploy_job.lock_value).to eq('$CI_JOB_NAME') + expect(project.ci_semaphores.count).to eq(1) + expect(project_semaphore.job_locks.count).to eq(1) + expect(test_job.job_lock).not_to be_present + expect(deploy_job.job_lock).to be_present + expect(deploy_job.job_lock.ci_semaphore).to eq(project_semaphore) + end + + it 'has the deploy job with locking status' do + result = execute_service + deploy_job = result.builds.find_by_name!(:deploy) + + expect(deploy_job).to be_created + expect(deploy_job.job_lock).to be_locking + end + + context 'when the other job obtains the lock already' do + before do + job = create(:ci_build) + ci_semaphore = project.ci_semaphores.create!(semaphore: 'deploy') + ci_semaphore.job_locks.create!(job: job) + end + + it 'has the deploy job with blocked status' do + result = execute_service + deploy_job = result.builds.find_by_name!(:deploy) + + expect(deploy_job).to be_created + expect(deploy_job.job_lock).to be_blocked + end + end + end + + context 'when Limit per job per branch' do + before do + config = YAML.dump( + test: { + script: 'ls', + lock: '$CI_COMMIT_REF_NAME:$CI_JOB_NAME' + } + ) + + stub_ci_pipeline_yaml_file(config) + end + + it 'persists lock attribute to build option' do + result = execute_service + test_job = result.builds.find_by_name!(:test) + + expect(result).to be_persisted + expect(test_job).to be_lockable + expect(project.ci_semaphores.exist?(semaphore: 'master:test')).to eq(true) + end + end + + context 'when Limit per environment' do + before do + config = YAML.dump( + test: { + script: 'ls', + environment: 'prd', + lock: '$CI_ENVIRONMENT_NAME' # This needs to fetch `persisted_environment_variables` in `simple_variables` + } + ) + + stub_ci_pipeline_yaml_file(config) + end + + it 'persists lock attribute to build option' do + result = execute_service + test_job = result.builds.find_by_name!(:test) + + expect(result).to be_persisted + expect(test_job).to be_lockable + expect(project.ci_semaphores.exist?(semaphore: 'prd')).to eq(true) + end + end + + context 'when two same locks exist in the same pipeline' do + # TODO: end end -- GitLab From ab510123ff09d3f8bd009c35cc051f9bb4ab0bb4 Mon Sep 17 00:00:00 2001 From: Shinya Maeda Date: Thu, 21 Nov 2019 15:36:42 +0700 Subject: [PATCH 4/8] Rename column --- app/models/ci/build.rb | 10 +++- app/models/ci/job_lock.rb | 6 +-- app/models/ci/project_semaphore.rb | 2 +- .../20191120184725_ci_project_semaphores.rb | 4 +- db/schema.rb | 4 +- lib/gitlab/ci/pipeline/seed/build/lock.rb | 2 +- .../ci/create_pipeline_service_spec.rb | 46 ++++--------------- 7 files changed, 26 insertions(+), 48 deletions(-) diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 0bf761dc6b0ff1..207ba3c51b7e62 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -430,10 +430,18 @@ def has_lock? options[:lock].present? end - def lock_value + def lock_key options.fetch(:lock, nil) end + def expanded_lock_key + return unless has_lock? + + strong_memoize(:expanded_lock_key) do + ExpandVariables.expand(lock_key, -> { simple_variables }) + end + end + def starts_environment? has_environment? && self.environment_action == 'start' end diff --git a/app/models/ci/job_lock.rb b/app/models/ci/job_lock.rb index f2e36e346ab407..a886e6927ef8a0 100644 --- a/app/models/ci/job_lock.rb +++ b/app/models/ci/job_lock.rb @@ -10,11 +10,11 @@ class JobLock < ApplicationRecord delegate :under_limit?, to: :ci_semaphore state_machine :status, initial: :created do - event :lock do + event :obtain do transition created: :locking end - event :block do + event :wait do transition created: :blocked end @@ -39,7 +39,7 @@ class JobLock < ApplicationRecord } def try_lock - under_limit? ? lock : block + under_limit? ? obtain : wait end end end diff --git a/app/models/ci/project_semaphore.rb b/app/models/ci/project_semaphore.rb index 3dd5a6bcfbe3a7..3dfdbf4dd78f50 100644 --- a/app/models/ci/project_semaphore.rb +++ b/app/models/ci/project_semaphore.rb @@ -8,7 +8,7 @@ class ProjectSemaphore < ApplicationRecord has_many :job_locks, class_name: 'Ci::JobLock', foreign_key: :semaphore_id - validates :semaphore, presence: true + validates :key, length: { minimum: 1, maximum: 255 } def under_limit? job_locks.locking.count < concurrency diff --git a/db/migrate/20191120184725_ci_project_semaphores.rb b/db/migrate/20191120184725_ci_project_semaphores.rb index 738f8abeea3ce6..40c6392fc7d20e 100644 --- a/db/migrate/20191120184725_ci_project_semaphores.rb +++ b/db/migrate/20191120184725_ci_project_semaphores.rb @@ -6,9 +6,9 @@ class CiProjectSemaphores < ActiveRecord::Migration[5.2] def change create_table :ci_project_semaphores do |t| t.references :project, null: false, index: false, foreign_key: { on_delete: :cascade } - t.string :semaphore, null: false + t.string :key, null: false t.integer :concurrency, null: false, default: 1 - t.index %i[project_id semaphore], unique: true + t.index %i[project_id key], unique: true t.timestamps_with_timezone end diff --git a/db/schema.rb b/db/schema.rb index 0681184805c28b..7c12ac5c48db89 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -867,11 +867,11 @@ create_table "ci_project_semaphores", force: :cascade do |t| t.bigint "project_id", null: false - t.string "semaphore", null: false + t.string "key", null: false t.integer "concurrency", default: 1, null: false t.datetime_with_timezone "created_at", null: false t.datetime_with_timezone "updated_at", null: false - t.index ["project_id", "semaphore"], name: "index_ci_project_semaphores_on_project_id_and_semaphore", unique: true + t.index ["project_id", "key"], name: "index_ci_project_semaphores_on_project_id_and_key", unique: true end create_table "ci_runner_namespaces", id: :serial, force: :cascade do |t| diff --git a/lib/gitlab/ci/pipeline/seed/build/lock.rb b/lib/gitlab/ci/pipeline/seed/build/lock.rb index 966d82bbfbbdc8..f5392519e98484 100644 --- a/lib/gitlab/ci/pipeline/seed/build/lock.rb +++ b/lib/gitlab/ci/pipeline/seed/build/lock.rb @@ -36,7 +36,7 @@ def find_or_create_semaphore def attributes_for_semaphore { project: job.project, - semaphore: job.expanded_lock_value + key: job.expanded_lock_key } end end diff --git a/spec/services/ci/create_pipeline_service_spec.rb b/spec/services/ci/create_pipeline_service_spec.rb index b4b77b22dc9dbc..25e436c95d2488 100644 --- a/spec/services/ci/create_pipeline_service_spec.rb +++ b/spec/services/ci/create_pipeline_service_spec.rb @@ -914,13 +914,13 @@ def previous_commit_sha_from_ref(ref) context 'when the other job has already obtained the lock' do before do - job = create(:ci_build) - ci_semaphore = project.ci_semaphores.create!(semaphore: 'deploy') - ci_semaphore.job_locks.create!(job: job) + ci_semaphore = project.ci_semaphores.create!(key: 'tmp-key') + ci_semaphore.job_locks.create!(job: create(:ci_build)).obtain! end it 'has the deploy job with blocked status' do result = execute_service + test_job = result.builds.find_by_name!(:test) deploy_job = result.builds.find_by_name!(:deploy) expect(deploy_job).to be_created @@ -939,14 +939,8 @@ def previous_commit_sha_from_ref(ref) context 'when Limit per job' do before do config = YAML.dump( - test: { - script: 'ls' - }, - deploy: { - script: 'ls', - environment: { name: 'prd' }, - lock: '$CI_JOB_NAME' - } + test: { stage: 'test', script: 'ls' }, + deploy: { stage: 'deploy', script: 'ls', lock: '$CI_JOB_NAME', environment: 'prd' } ) stub_ci_pipeline_yaml_file(config) @@ -968,30 +962,6 @@ def previous_commit_sha_from_ref(ref) expect(deploy_job.job_lock).to be_present expect(deploy_job.job_lock.ci_semaphore).to eq(project_semaphore) end - - it 'has the deploy job with locking status' do - result = execute_service - deploy_job = result.builds.find_by_name!(:deploy) - - expect(deploy_job).to be_created - expect(deploy_job.job_lock).to be_locking - end - - context 'when the other job obtains the lock already' do - before do - job = create(:ci_build) - ci_semaphore = project.ci_semaphores.create!(semaphore: 'deploy') - ci_semaphore.job_locks.create!(job: job) - end - - it 'has the deploy job with blocked status' do - result = execute_service - deploy_job = result.builds.find_by_name!(:deploy) - - expect(deploy_job).to be_created - expect(deploy_job.job_lock).to be_blocked - end - end end context 'when Limit per job per branch' do @@ -1012,7 +982,7 @@ def previous_commit_sha_from_ref(ref) expect(result).to be_persisted expect(test_job).to be_lockable - expect(project.ci_semaphores.exist?(semaphore: 'master:test')).to eq(true) + expect(project.ci_semaphores.exist?(key: 'master:test')).to eq(true) end end @@ -1035,12 +1005,12 @@ def previous_commit_sha_from_ref(ref) expect(result).to be_persisted expect(test_job).to be_lockable - expect(project.ci_semaphores.exist?(semaphore: 'prd')).to eq(true) + expect(project.ci_semaphores.exist?(key: 'prd')).to eq(true) end end context 'when two same locks exist in the same pipeline' do - # TODO: + # TODO: Test edge case end end -- GitLab From 835208e30106232ebf0ee28b07a23e224cd8bb1c Mon Sep 17 00:00:00 2001 From: Shinya Maeda Date: Thu, 21 Nov 2019 16:11:54 +0700 Subject: [PATCH 5/8] Fix release --- app/models/ci/build.rb | 2 ++ app/models/ci/job_lock.rb | 12 +++++--- app/models/ci/project_semaphore.rb | 8 +++++ .../ci/create_pipeline_service_spec.rb | 29 ++++++++++++++++++- 4 files changed, 46 insertions(+), 5 deletions(-) diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 207ba3c51b7e62..c7df5c4c68bdc2 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -259,6 +259,8 @@ def retry(build, current_user) end after_transition any => [:success, :failed, :canceled] do |build| + build.job_lock&.release + build.run_after_commit do BuildFinishedWorker.perform_async(id) end diff --git a/app/models/ci/job_lock.rb b/app/models/ci/job_lock.rb index a886e6927ef8a0..9cf251876e1d5d 100644 --- a/app/models/ci/job_lock.rb +++ b/app/models/ci/job_lock.rb @@ -7,11 +7,11 @@ class JobLock < ApplicationRecord belongs_to :ci_semaphore, class_name: 'Ci::ProjectSemaphore', foreign_key: :semaphore_id, inverse_of: :job_locks belongs_to :job, class_name: 'Ci::Build', inverse_of: :job_lock - delegate :under_limit?, to: :ci_semaphore + delegate :under_limit?, :unlock_next, to: :ci_semaphore state_machine :status, initial: :created do event :obtain do - transition created: :locking + transition %i[created blocked] => :locking end event :wait do @@ -23,10 +23,14 @@ class JobLock < ApplicationRecord end before_transition blocked: :locking do |job_lock| - build.blocked_duration = Time.now - job_lock.updated_at + job_lock.blocked_duration = Time.now - job_lock.updated_at end - after_transition created: :locking do |job_lock| + before_transition any => :released do |job_lock| + job_lock.unlock_next(from: job_lock) + end + + after_transition %i[created blocked] => :locking do |job_lock| job_lock.job.enqueue end end diff --git a/app/models/ci/project_semaphore.rb b/app/models/ci/project_semaphore.rb index 3dfdbf4dd78f50..5f7064269b74a6 100644 --- a/app/models/ci/project_semaphore.rb +++ b/app/models/ci/project_semaphore.rb @@ -13,5 +13,13 @@ class ProjectSemaphore < ApplicationRecord def under_limit? job_locks.locking.count < concurrency end + + def unlock_next(from:) + next_blocked(from.id)&.obtain + end + + def next_blocked(from_id) + job_locks.blocked.where('id > ?', from_id).order(:id).take + end end end diff --git a/spec/services/ci/create_pipeline_service_spec.rb b/spec/services/ci/create_pipeline_service_spec.rb index 25e436c95d2488..ab8d8ec5c83be0 100644 --- a/spec/services/ci/create_pipeline_service_spec.rb +++ b/spec/services/ci/create_pipeline_service_spec.rb @@ -910,12 +910,22 @@ def previous_commit_sha_from_ref(ref) expect(deploy_job).to be_pending expect(deploy_job.job_lock).to be_locking + + # when deploy job finished + deploy_job.success! + deploy_job.reload + + expect(deploy_job).to be_success + expect(deploy_job.job_lock).to be_released end context 'when the other job has already obtained the lock' do + let(:other_job) { create(:ci_build, name: 'other') } + before do ci_semaphore = project.ci_semaphores.create!(key: 'tmp-key') - ci_semaphore.job_locks.create!(job: create(:ci_build)).obtain! + ci_semaphore.job_locks.create!(job: other_job).obtain! + other_job.reload end it 'has the deploy job with blocked status' do @@ -932,6 +942,23 @@ def previous_commit_sha_from_ref(ref) expect(deploy_job).to be_created expect(deploy_job.job_lock).to be_blocked + + # when other job finished + other_job.success! + other_job.reload + deploy_job.reload + + expect(other_job).to be_success + expect(other_job.job_lock).to be_released + expect(deploy_job).to be_pending + expect(deploy_job.job_lock).to be_locking + + # when deploy job finished + deploy_job.success! + deploy_job.reload + + expect(deploy_job).to be_success + expect(deploy_job.job_lock).to be_released end end end -- GitLab From 8b3625e678a9b0271639e43b0bfea3d92733d391 Mon Sep 17 00:00:00 2001 From: Shinya Maeda Date: Thu, 21 Nov 2019 18:20:46 +0700 Subject: [PATCH 6/8] Fix spec --- app/models/ci/job_lock.rb | 4 +++- lib/gitlab/ci/pipeline/seed/build/lock.rb | 3 +-- spec/services/ci/create_pipeline_service_spec.rb | 8 ++++---- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/app/models/ci/job_lock.rb b/app/models/ci/job_lock.rb index 9cf251876e1d5d..7996b155bd4726 100644 --- a/app/models/ci/job_lock.rb +++ b/app/models/ci/job_lock.rb @@ -4,7 +4,9 @@ module Ci class JobLock < ApplicationRecord self.table_name = 'ci_job_locks' - belongs_to :ci_semaphore, class_name: 'Ci::ProjectSemaphore', foreign_key: :semaphore_id, inverse_of: :job_locks + belongs_to :ci_semaphore, class_name: 'Ci::ProjectSemaphore', + foreign_key: :semaphore_id, inverse_of: :job_locks + belongs_to :job, class_name: 'Ci::Build', inverse_of: :job_lock delegate :under_limit?, :unlock_next, to: :ci_semaphore diff --git a/lib/gitlab/ci/pipeline/seed/build/lock.rb b/lib/gitlab/ci/pipeline/seed/build/lock.rb index f5392519e98484..88e7d8352dfb00 100644 --- a/lib/gitlab/ci/pipeline/seed/build/lock.rb +++ b/lib/gitlab/ci/pipeline/seed/build/lock.rb @@ -17,9 +17,8 @@ def to_resource semaphore = find_or_create_semaphore - unless semaphore + unless semaphore.valid? && semaphore.persisted? # TODO: Gitlab::Sentry.track_exception or invalid parameters - raise ArgumentError return end diff --git a/spec/services/ci/create_pipeline_service_spec.rb b/spec/services/ci/create_pipeline_service_spec.rb index ab8d8ec5c83be0..03b1e05d016a75 100644 --- a/spec/services/ci/create_pipeline_service_spec.rb +++ b/spec/services/ci/create_pipeline_service_spec.rb @@ -977,12 +977,12 @@ def previous_commit_sha_from_ref(ref) result = execute_service test_job = result.builds.find_by_name!(:test) deploy_job = result.builds.find_by_name!(:deploy) - project_semaphore = project.ci_semaphores.find_by_semaphore!('deploy') + project_semaphore = project.ci_semaphores.find_by_key!('deploy') expect(result).to be_persisted expect(test_job).not_to be_has_lock expect(deploy_job).to be_has_lock - expect(deploy_job.lock_value).to eq('$CI_JOB_NAME') + expect(deploy_job.lock_key).to eq('$CI_JOB_NAME') expect(project.ci_semaphores.count).to eq(1) expect(project_semaphore.job_locks.count).to eq(1) expect(test_job.job_lock).not_to be_present @@ -1009,7 +1009,7 @@ def previous_commit_sha_from_ref(ref) expect(result).to be_persisted expect(test_job).to be_lockable - expect(project.ci_semaphores.exist?(key: 'master:test')).to eq(true) + expect(project.ci_semaphores.exists?(key: 'master:test')).to eq(true) end end @@ -1019,7 +1019,7 @@ def previous_commit_sha_from_ref(ref) test: { script: 'ls', environment: 'prd', - lock: '$CI_ENVIRONMENT_NAME' # This needs to fetch `persisted_environment_variables` in `simple_variables` + lock: '$CI_ENVIRONMENT_NAME' # TODO: This needs to fetch `persisted_environment_variables` in `simple_variables` } ) -- GitLab From a1d852f13352eb2e842a6265696887167425419b Mon Sep 17 00:00:00 2001 From: Shinya Maeda Date: Thu, 21 Nov 2019 18:46:27 +0700 Subject: [PATCH 7/8] Introduce detailed status --- lib/gitlab/ci/status/blocked_semaphore.rb | 33 ------------------- .../ci/status/build/blocked_by_semaphore.rb | 31 +++++++++++++++++ lib/gitlab/ci/status/build/factory.rb | 1 + .../status/pipeline/blocked_by_semaphore.rb | 31 +++++++++++++++++ lib/gitlab/ci/status/pipeline/factory.rb | 3 +- .../ci/status/stage/blocked_by_semaphore.rb | 31 +++++++++++++++++ lib/gitlab/ci/status/stage/factory.rb | 3 +- 7 files changed, 98 insertions(+), 35 deletions(-) delete mode 100644 lib/gitlab/ci/status/blocked_semaphore.rb create mode 100644 lib/gitlab/ci/status/build/blocked_by_semaphore.rb create mode 100644 lib/gitlab/ci/status/pipeline/blocked_by_semaphore.rb create mode 100644 lib/gitlab/ci/status/stage/blocked_by_semaphore.rb diff --git a/lib/gitlab/ci/status/blocked_semaphore.rb b/lib/gitlab/ci/status/blocked_semaphore.rb deleted file mode 100644 index 13ea615f4242bc..00000000000000 --- a/lib/gitlab/ci/status/blocked_semaphore.rb +++ /dev/null @@ -1,33 +0,0 @@ -# frozen_string_literal: true - -module Gitlab - module Ci - module Status - ## - # Extended status used when pipeline or stage passed conditionally. - # This means that failed jobs that are allowed to fail were present. - # - class BlockedSemaphore < Status::Extended - def text - s_('CiStatusText|passed') - end - - def label - s_('CiStatusLabel|passed with warnings') - end - - def icon - 'status_warning' - end - - def group - 'success-with-warnings' - end - - def self.matches?(subject, user) - subject.created? && subject.job_lock&.blocked? - end - end - end - end -end diff --git a/lib/gitlab/ci/status/build/blocked_by_semaphore.rb b/lib/gitlab/ci/status/build/blocked_by_semaphore.rb new file mode 100644 index 00000000000000..57c76ecddf1e00 --- /dev/null +++ b/lib/gitlab/ci/status/build/blocked_by_semaphore.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + module Status + module Build + class BlockedBySemaphore < Status::Extended + def text + s_('CiStatusText|blocked by semaphore') + end + + def label + s_('CiStatusLabel|blocked by semaphore') + end + + def icon + 'status_manual' + end + + def group + 'favicon_status_manual' + end + + def self.matches?(subject, user) + subject.job_lock&.blocked? # TODO: Hyper slow + end + end + end + end + end +end diff --git a/lib/gitlab/ci/status/build/factory.rb b/lib/gitlab/ci/status/build/factory.rb index 96d05842838531..b181a07cca2393 100644 --- a/lib/gitlab/ci/status/build/factory.rb +++ b/lib/gitlab/ci/status/build/factory.rb @@ -8,6 +8,7 @@ class Factory < Status::Factory def self.extended_statuses [[Status::Build::Erased, Status::Build::Scheduled, + Status::Build::BlockedBySemaphore, Status::Build::Manual, Status::Build::Canceled, Status::Build::Created, diff --git a/lib/gitlab/ci/status/pipeline/blocked_by_semaphore.rb b/lib/gitlab/ci/status/pipeline/blocked_by_semaphore.rb new file mode 100644 index 00000000000000..e5d78ebc6183ea --- /dev/null +++ b/lib/gitlab/ci/status/pipeline/blocked_by_semaphore.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + module Status + module Pipeline + class BlockedBySemaphore < Status::Extended + def text + s_('CiStatusText|blocked by semaphore') + end + + def label + s_('CiStatusLabel|blocked by semaphore') + end + + def icon + 'status_manual' + end + + def group + 'favicon_status_manual' + end + + def self.matches?(subject, user) + subject.builds.any? { |build| build.job_lock&.blocked? } # TODO: Hyper slow + end + end + end + end + end +end diff --git a/lib/gitlab/ci/status/pipeline/factory.rb b/lib/gitlab/ci/status/pipeline/factory.rb index 5d1a8bbd924080..37cc553204e2fe 100644 --- a/lib/gitlab/ci/status/pipeline/factory.rb +++ b/lib/gitlab/ci/status/pipeline/factory.rb @@ -8,7 +8,8 @@ class Factory < Status::Factory def self.extended_statuses [[Status::SuccessWarning, Status::Pipeline::Delayed, - Status::Pipeline::Blocked]] + Status::Pipeline::Blocked, + Status::Pipeline::BlockedBySemaphore]] end def self.common_helpers diff --git a/lib/gitlab/ci/status/stage/blocked_by_semaphore.rb b/lib/gitlab/ci/status/stage/blocked_by_semaphore.rb new file mode 100644 index 00000000000000..3a69486aefb768 --- /dev/null +++ b/lib/gitlab/ci/status/stage/blocked_by_semaphore.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Gitlab + module Ci + module Status + module Stage + class BlockedBySemaphore < Status::Extended + def text + s_('CiStatusText|blocked by semaphore') + end + + def label + s_('CiStatusLabel|blocked by semaphore') + end + + def icon + 'status_manual' + end + + def group + 'favicon_status_manual' + end + + def self.matches?(subject, user) + subject.builds.any? { |build| build.job_lock&.blocked? } # TODO: Hyper slow + end + end + end + end + end +end diff --git a/lib/gitlab/ci/status/stage/factory.rb b/lib/gitlab/ci/status/stage/factory.rb index e50b08537255c6..44b4547a57c3d0 100644 --- a/lib/gitlab/ci/status/stage/factory.rb +++ b/lib/gitlab/ci/status/stage/factory.rb @@ -7,7 +7,8 @@ module Stage class Factory < Status::Factory def self.extended_statuses [[Status::SuccessWarning], - [Status::Stage::PlayManual]] + [Status::Stage::PlayManual], + [Status::Stage::BlockedBySemaphore]] end def self.common_helpers -- GitLab From 22c3a5020ae204ec7392de097c3355c6d575c16d Mon Sep 17 00:00:00 2001 From: Shinya Maeda Date: Thu, 21 Nov 2019 18:47:11 +0700 Subject: [PATCH 8/8] Use compound status --- lib/gitlab/ci/status/build/blocked_by_semaphore.rb | 2 +- lib/gitlab/ci/status/pipeline/blocked_by_semaphore.rb | 2 +- lib/gitlab/ci/status/stage/blocked_by_semaphore.rb | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/gitlab/ci/status/build/blocked_by_semaphore.rb b/lib/gitlab/ci/status/build/blocked_by_semaphore.rb index 57c76ecddf1e00..1a69c517d8198e 100644 --- a/lib/gitlab/ci/status/build/blocked_by_semaphore.rb +++ b/lib/gitlab/ci/status/build/blocked_by_semaphore.rb @@ -22,7 +22,7 @@ def group end def self.matches?(subject, user) - subject.job_lock&.blocked? # TODO: Hyper slow + subject.job_lock&.blocked? # TODO: Hyper slow. User compond status. end end end diff --git a/lib/gitlab/ci/status/pipeline/blocked_by_semaphore.rb b/lib/gitlab/ci/status/pipeline/blocked_by_semaphore.rb index e5d78ebc6183ea..a7acded5929101 100644 --- a/lib/gitlab/ci/status/pipeline/blocked_by_semaphore.rb +++ b/lib/gitlab/ci/status/pipeline/blocked_by_semaphore.rb @@ -22,7 +22,7 @@ def group end def self.matches?(subject, user) - subject.builds.any? { |build| build.job_lock&.blocked? } # TODO: Hyper slow + subject.builds.any? { |build| build.job_lock&.blocked? } # TODO: Hyper slow. User compond status. end end end diff --git a/lib/gitlab/ci/status/stage/blocked_by_semaphore.rb b/lib/gitlab/ci/status/stage/blocked_by_semaphore.rb index 3a69486aefb768..1d55daf0da6061 100644 --- a/lib/gitlab/ci/status/stage/blocked_by_semaphore.rb +++ b/lib/gitlab/ci/status/stage/blocked_by_semaphore.rb @@ -22,7 +22,7 @@ def group end def self.matches?(subject, user) - subject.builds.any? { |build| build.job_lock&.blocked? } # TODO: Hyper slow + subject.builds.any? { |build| build.job_lock&.blocked? } # TODO: Hyper slow. User compond status. end end end -- GitLab