Commit 499988d3 authored by Bob Van Landuyt's avatar Bob Van Landuyt

Merge branch 'fix-ci-extra-minutes-recalculation' into 'master'

Recalculate CI extra minutes when monthly minutes defaults to nil

See merge request gitlab-org/gitlab!36046
parents bf544a6a d1cbb548
...@@ -36,13 +36,6 @@ module EE ...@@ -36,13 +36,6 @@ module EE
scope :include_gitlab_subscription, -> { includes(:gitlab_subscription) } scope :include_gitlab_subscription, -> { includes(:gitlab_subscription) }
scope :join_gitlab_subscription, -> { joins("LEFT OUTER JOIN gitlab_subscriptions ON gitlab_subscriptions.namespace_id=namespaces.id") } scope :join_gitlab_subscription, -> { joins("LEFT OUTER JOIN gitlab_subscriptions ON gitlab_subscriptions.namespace_id=namespaces.id") }
scope :requiring_ci_extra_minutes_recalculation, -> do
joins(:namespace_statistics)
.where('namespaces.shared_runners_minutes_limit > 0')
.where('namespaces.extra_shared_runners_minutes_limit > 0')
.where('namespace_statistics.shared_runners_seconds > (namespaces.shared_runners_minutes_limit * 60)')
end
scope :with_feature_available_in_plan, -> (feature) do scope :with_feature_available_in_plan, -> (feature) do
plans = plans_with_feature(feature) plans = plans_with_feature(feature)
matcher = ::Plan.where(name: plans) matcher = ::Plan.where(name: plans)
......
...@@ -39,14 +39,44 @@ module Ci ...@@ -39,14 +39,44 @@ module Ci
@errors << { count: namespaces.size, first_id: namespaces.first.id, last_id: namespaces.last.id } @errors << { count: namespaces.size, first_id: namespaces.first.id, last_id: namespaces.last.id }
end end
# This service is responsible for the logic that recalculates the extra shared runners
# minutes including how to deal with the cases where shared_runners_minutes_limit is `nil`.
# We prefer to keep the queries here rather than scatter them across classes.
# rubocop: disable CodeReuse/ActiveRecord
def recalculate_extra_shared_runners_minutes_limits!(namespaces) def recalculate_extra_shared_runners_minutes_limits!(namespaces)
namespaces namespaces
.requiring_ci_extra_minutes_recalculation .joins(:namespace_statistics)
.update_all("extra_shared_runners_minutes_limit = #{extra_minutes_left_sql} FROM namespace_statistics") .where(namespaces_arel[:extra_shared_runners_minutes_limit].gt(0))
.where(actual_shared_runners_minutes_limit.gt(0))
.where(namespaces_statistics_arel[:shared_runners_seconds].gt(actual_shared_runners_minutes_limit * 60))
.update_all("extra_shared_runners_minutes_limit = #{extra_minutes_left.to_sql} FROM namespace_statistics")
end end
# rubocop: enable CodeReuse/ActiveRecord
def extra_minutes_left_sql def extra_minutes_left
"GREATEST((namespaces.shared_runners_minutes_limit + namespaces.extra_shared_runners_minutes_limit) - ROUND(namespace_statistics.shared_runners_seconds / 60.0), 0)" shared_minutes_limit = actual_shared_runners_minutes_limit + namespaces_arel[:extra_shared_runners_minutes_limit]
used_minutes = arel_function("round", [namespaces_statistics_arel[:shared_runners_seconds] / Arel::Nodes::SqlLiteral.new('60.0')])
arel_function("greatest", [shared_minutes_limit - used_minutes, 0])
end
def actual_shared_runners_minutes_limit
namespaces_arel.coalesce(
namespaces_arel[:shared_runners_minutes_limit],
[::Gitlab::CurrentSettings.shared_runners_minutes.presence, 0].compact
)
end
def namespaces_arel
Namespace.arel_table
end
def namespaces_statistics_arel
NamespaceStatistics.arel_table
end
def arel_function(name, attrs)
Arel::Nodes::NamedFunction.new(name, attrs)
end end
def reset_shared_runners_seconds!(namespaces) def reset_shared_runners_seconds!(namespaces)
......
---
title: Recalculate CI extra minutes when monthly minutes default to nil
merge_request: 36046
author:
type: fixed
...@@ -6,10 +6,14 @@ RSpec.describe Ci::Minutes::BatchResetService do ...@@ -6,10 +6,14 @@ RSpec.describe Ci::Minutes::BatchResetService do
let(:service) { described_class.new } let(:service) { described_class.new }
describe '#execute!' do describe '#execute!' do
def create_namespace_with_project(id, seconds_used) let(:ids_range) { nil }
subject { service.execute!(ids_range: ids_range, batch_size: 3) }
def create_namespace_with_project(id, seconds_used, monthly_minutes_limit = nil)
namespace = create(:namespace, namespace = create(:namespace,
id: id, id: id,
shared_runners_minutes_limit: 100, shared_runners_minutes_limit: monthly_minutes_limit, # when `nil` it inherits the global limit
extra_shared_runners_minutes_limit: 50, extra_shared_runners_minutes_limit: 50,
last_ci_minutes_notification_at: Time.current, last_ci_minutes_notification_at: Time.current,
last_ci_minutes_usage_notification_level: 30) last_ci_minutes_usage_notification_level: 30)
...@@ -26,98 +30,142 @@ RSpec.describe Ci::Minutes::BatchResetService do ...@@ -26,98 +30,142 @@ RSpec.describe Ci::Minutes::BatchResetService do
namespace namespace
end end
subject { service.execute!(ids_range: ids_range, batch_size: 3) } context 'when global shared_runners_minutes is enabled' do
before do
allow(::Gitlab::CurrentSettings).to receive(:shared_runners_minutes).and_return(2_000)
end
let!(:namespace_1) { create_namespace_with_project(1, 120.minutes) } let!(:namespace_1) { create_namespace_with_project(1, 2_020.minutes, nil) }
let!(:namespace_2) { create_namespace_with_project(2, 120.minutes) } let!(:namespace_2) { create_namespace_with_project(2, 2_020.minutes, 2_000) }
let!(:namespace_3) { create_namespace_with_project(3, 120.minutes) } let!(:namespace_3) { create_namespace_with_project(3, 2_020.minutes, 2_000) }
let!(:namespace_4) { create_namespace_with_project(4, 90.minutes) } let!(:namespace_4) { create_namespace_with_project(4, 1_000.minutes, nil) }
let!(:namespace_5) { create_namespace_with_project(5, 90.minutes) } let!(:namespace_5) { create_namespace_with_project(5, 1_000.minutes, 2_000) }
let!(:namespace_6) { create_namespace_with_project(6, 90.minutes) } let!(:namespace_6) { create_namespace_with_project(6, 1_000.minutes, 0) }
context 'when ID range is provided' do context 'when ID range is provided' do
let(:ids_range) { (1..5) } let(:ids_range) { (1..5) }
let(:namespaces_exceeding_minutes) { [namespace_1, namespace_2, namespace_3] } let(:namespaces_exceeding_minutes) { [namespace_1, namespace_2, namespace_3] }
let(:namespaces_not_exceeding_minutes) { [namespace_4, namespace_5] } let(:namespaces_not_exceeding_minutes) { [namespace_4, namespace_5] }
it 'resets minutes in batches for the given range' do it 'resets minutes in batches for the given range' do
expect(service).to receive(:reset_ci_minutes!).with([namespace_1, namespace_2, namespace_3]) expect(service).to receive(:reset_ci_minutes!).with([namespace_1, namespace_2, namespace_3])
expect(service).to receive(:reset_ci_minutes!).with([namespace_4, namespace_5]) expect(service).to receive(:reset_ci_minutes!).with([namespace_4, namespace_5])
subject subject
end end
it 'resets CI minutes and recalculates purchased minutes for the namespace exceeding the monthly minutes' do it 'resets CI minutes and recalculates purchased minutes for the namespace exceeding the monthly minutes' do
subject subject
namespaces_exceeding_minutes.each do |namespace| namespaces_exceeding_minutes.each do |namespace|
namespace.reset namespace.reset
expect(namespace.extra_shared_runners_minutes_limit).to eq 30
expect(namespace.namespace_statistics.shared_runners_seconds).to eq 0
expect(namespace.namespace_statistics.shared_runners_seconds_last_reset).to be_present
expect(ProjectStatistics.find_by(namespace: namespace).shared_runners_seconds).to eq 0
expect(ProjectStatistics.find_by(namespace: namespace).shared_runners_seconds_last_reset).to be_present
expect(namespace.last_ci_minutes_notification_at).to be_nil
expect(namespace.last_ci_minutes_usage_notification_level).to be_nil
end
end
it 'resets CI minutes but does not recalculate purchased minutes for the namespace not exceeding the monthly minutes' do
subject
expect(namespace.extra_shared_runners_minutes_limit).to eq 30 namespaces_not_exceeding_minutes.each do |namespace|
expect(namespace.namespace_statistics.shared_runners_seconds).to eq 0 namespace.reset
expect(namespace.namespace_statistics.shared_runners_seconds_last_reset).to be_present
expect(ProjectStatistics.find_by(namespace: namespace).shared_runners_seconds).to eq 0 expect(namespace.extra_shared_runners_minutes_limit).to eq 50
expect(ProjectStatistics.find_by(namespace: namespace).shared_runners_seconds_last_reset).to be_present expect(namespace.namespace_statistics.shared_runners_seconds).to eq 0
expect(namespace.last_ci_minutes_notification_at).to be_nil expect(namespace.namespace_statistics.shared_runners_seconds_last_reset).to be_present
expect(namespace.last_ci_minutes_usage_notification_level).to be_nil expect(ProjectStatistics.find_by(namespace: namespace).shared_runners_seconds).to eq 0
expect(ProjectStatistics.find_by(namespace: namespace).shared_runners_seconds_last_reset).to be_present
expect(namespace.last_ci_minutes_notification_at).to be_nil
expect(namespace.last_ci_minutes_usage_notification_level).to be_nil
end
end end
end end
it 'resets CI minutes but does not recalculate purchased minutes for the namespace not exceeding the monthly minutes' do context 'when ID range is not provided' do
subject let(:ids_range) { nil }
namespaces_not_exceeding_minutes.each do |namespace| it 'resets minutes in batches for all namespaces' do
namespace.reset expect(service).to receive(:reset_ci_minutes!).with([namespace_1, namespace_2, namespace_3])
expect(service).to receive(:reset_ci_minutes!).with([namespace_4, namespace_5, namespace_6])
expect(namespace.extra_shared_runners_minutes_limit).to eq 50 subject
expect(namespace.namespace_statistics.shared_runners_seconds).to eq 0 end
expect(namespace.namespace_statistics.shared_runners_seconds_last_reset).to be_present
expect(ProjectStatistics.find_by(namespace: namespace).shared_runners_seconds).to eq 0 it 'resets CI minutes and does not recalculate purchased minutes for the namespace having unlimited monthly minutes' do
expect(ProjectStatistics.find_by(namespace: namespace).shared_runners_seconds_last_reset).to be_present subject
expect(namespace.last_ci_minutes_notification_at).to be_nil
expect(namespace.last_ci_minutes_usage_notification_level).to be_nil namespace_6.reset
expect(namespace_6.extra_shared_runners_minutes_limit).to eq 50
expect(namespace_6.namespace_statistics.shared_runners_seconds).to eq 0
expect(namespace_6.namespace_statistics.shared_runners_seconds_last_reset).to be_present
expect(ProjectStatistics.find_by(namespace: namespace_6).shared_runners_seconds).to eq 0
expect(ProjectStatistics.find_by(namespace: namespace_6).shared_runners_seconds_last_reset).to be_present
expect(namespace_6.last_ci_minutes_notification_at).to be_nil
expect(namespace_6.last_ci_minutes_usage_notification_level).to be_nil
end end
end end
end
context 'when ID range is not provided' do context 'when an ActiveRecordError is raised' do
let(:ids_range) { nil } let(:ids_range) { nil }
it 'resets minutes in batches for all namespaces' do before do
expect(service).to receive(:reset_ci_minutes!).with([namespace_1, namespace_2, namespace_3]) expect(Namespace).to receive(:transaction).once.ordered.and_raise(ActiveRecord::ActiveRecordError)
expect(service).to receive(:reset_ci_minutes!).with([namespace_4, namespace_5, namespace_6]) expect(Namespace).to receive(:transaction).once.ordered.and_call_original
end
it 'continues its progress' do
expect(service).to receive(:reset_ci_minutes!).with([namespace_1, namespace_2, namespace_3]).and_call_original
expect(service).to receive(:reset_ci_minutes!).with([namespace_4, namespace_5, namespace_6]).and_call_original
expect(Gitlab::ErrorTracking).to receive(:track_and_raise_exception)
subject
end
subject it 'raises exception with namespace details' do
expect(Gitlab::ErrorTracking).to receive(
:track_and_raise_exception
).with(
Ci::Minutes::BatchResetService::BatchNotResetError.new(
'Some namespace shared runner minutes were not reset.'
),
{ namespace_ranges: [{ count: 3, first_id: 1, last_id: 3 }] }
).once.and_call_original
expect { subject }.to raise_error(Ci::Minutes::BatchResetService::BatchNotResetError)
end
end end
end end
context 'when an ActiveRecordError is raised' do context 'when global shared_runners_minutes setting is nil and namespaces have no limits' do
let(:ids_range) { nil } using RSpec::Parameterized::TableSyntax
before do where(:global_limit, :namespace_limit) do
expect(Namespace).to receive(:transaction).once.ordered.and_raise(ActiveRecord::ActiveRecordError) nil | 0
expect(Namespace).to receive(:transaction).once.ordered.and_call_original nil | nil
0 | 0
0 | nil
end end
it 'continues its progress' do with_them do
expect(service).to receive(:reset_ci_minutes!).with([namespace_1, namespace_2, namespace_3]).and_call_original let!(:namespace) { create_namespace_with_project(1, 100.minutes, namespace_limit) }
expect(service).to receive(:reset_ci_minutes!).with([namespace_4, namespace_5, namespace_6]).and_call_original
expect(Gitlab::ErrorTracking).to receive(:track_and_raise_exception) before do
subject allow(::Gitlab::CurrentSettings).to receive(:shared_runners_minutes).and_return(global_limit)
end end
it 'raises exception with namespace details' do it 'does not recalculate purchased minutes for any namespaces' do
expect(Gitlab::ErrorTracking).to receive( subject
:track_and_raise_exception
).with( namespace.reset
Ci::Minutes::BatchResetService::BatchNotResetError.new( expect(namespace.extra_shared_runners_minutes_limit).to eq 50
'Some namespace shared runner minutes were not reset.' end
),
{ namespace_ranges: [{ count: 3, first_id: 1, last_id: 3 }] }
).once.and_call_original
expect { subject }.to raise_error(Ci::Minutes::BatchResetService::BatchNotResetError)
end end
end end
end end
......
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