Commit 12433d0c authored by Fabio Pitino's avatar Fabio Pitino

Track CI minutes for namespace on a monthly basis

Introduce a new table `ci_namespace_monthly_usages` that
tracks CI minutes usage on a monthly basis.
Refactor UpdateBuildMinutesService to use both new and
legacy tracking until the new one is viable.
parent 966392e7
---
title: Track CI minutes for namespace on a monthly basis
merge_request: 52915
author:
type: added
# frozen_string_literal: true
class CreateCiNamespaceMonthlyUsage < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
def up
with_lock_retries do
create_table :ci_namespace_monthly_usages, if_not_exists: true do |t|
t.references :namespace, index: false, null: false
t.date :date, null: false
t.integer :additional_amount_available, null: false, default: 0
t.decimal :amount_used, null: false, default: 0.0, precision: 18, scale: 2
t.index [:namespace_id, :date], unique: true
end
end
add_check_constraint :ci_namespace_monthly_usages, "(date = date_trunc('month', date))", 'ci_namespace_monthly_usages_year_month_constraint'
end
def down
with_lock_retries do
drop_table :ci_namespace_monthly_usages
end
end
end
932509d18f1cfdfa09f1565e4ac2f197b7ca792263ff5da3e5b712fae7279925
\ No newline at end of file
...@@ -10505,6 +10505,24 @@ CREATE SEQUENCE ci_job_variables_id_seq ...@@ -10505,6 +10505,24 @@ CREATE SEQUENCE ci_job_variables_id_seq
ALTER SEQUENCE ci_job_variables_id_seq OWNED BY ci_job_variables.id; ALTER SEQUENCE ci_job_variables_id_seq OWNED BY ci_job_variables.id;
CREATE TABLE ci_namespace_monthly_usages (
id bigint NOT NULL,
namespace_id bigint NOT NULL,
date date NOT NULL,
additional_amount_available integer DEFAULT 0 NOT NULL,
amount_used numeric(18,2) DEFAULT 0.0 NOT NULL,
CONSTRAINT ci_namespace_monthly_usages_year_month_constraint CHECK ((date = date_trunc('month'::text, (date)::timestamp with time zone)))
);
CREATE SEQUENCE ci_namespace_monthly_usages_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER SEQUENCE ci_namespace_monthly_usages_id_seq OWNED BY ci_namespace_monthly_usages.id;
CREATE TABLE ci_pipeline_artifacts ( CREATE TABLE ci_pipeline_artifacts (
id bigint NOT NULL, id bigint NOT NULL,
created_at timestamp with time zone NOT NULL, created_at timestamp with time zone NOT NULL,
...@@ -18741,6 +18759,8 @@ ALTER TABLE ONLY ci_job_artifacts ALTER COLUMN id SET DEFAULT nextval('ci_job_ar ...@@ -18741,6 +18759,8 @@ ALTER TABLE ONLY ci_job_artifacts ALTER COLUMN id SET DEFAULT nextval('ci_job_ar
ALTER TABLE ONLY ci_job_variables ALTER COLUMN id SET DEFAULT nextval('ci_job_variables_id_seq'::regclass); ALTER TABLE ONLY ci_job_variables ALTER COLUMN id SET DEFAULT nextval('ci_job_variables_id_seq'::regclass);
ALTER TABLE ONLY ci_namespace_monthly_usages ALTER COLUMN id SET DEFAULT nextval('ci_namespace_monthly_usages_id_seq'::regclass);
ALTER TABLE ONLY ci_pipeline_artifacts ALTER COLUMN id SET DEFAULT nextval('ci_pipeline_artifacts_id_seq'::regclass); ALTER TABLE ONLY ci_pipeline_artifacts ALTER COLUMN id SET DEFAULT nextval('ci_pipeline_artifacts_id_seq'::regclass);
ALTER TABLE ONLY ci_pipeline_chat_data ALTER COLUMN id SET DEFAULT nextval('ci_pipeline_chat_data_id_seq'::regclass); ALTER TABLE ONLY ci_pipeline_chat_data ALTER COLUMN id SET DEFAULT nextval('ci_pipeline_chat_data_id_seq'::regclass);
...@@ -19854,6 +19874,9 @@ ALTER TABLE ONLY ci_job_artifacts ...@@ -19854,6 +19874,9 @@ ALTER TABLE ONLY ci_job_artifacts
ALTER TABLE ONLY ci_job_variables ALTER TABLE ONLY ci_job_variables
ADD CONSTRAINT ci_job_variables_pkey PRIMARY KEY (id); ADD CONSTRAINT ci_job_variables_pkey PRIMARY KEY (id);
ALTER TABLE ONLY ci_namespace_monthly_usages
ADD CONSTRAINT ci_namespace_monthly_usages_pkey PRIMARY KEY (id);
ALTER TABLE ONLY ci_pipeline_artifacts ALTER TABLE ONLY ci_pipeline_artifacts
ADD CONSTRAINT ci_pipeline_artifacts_pkey PRIMARY KEY (id); ADD CONSTRAINT ci_pipeline_artifacts_pkey PRIMARY KEY (id);
...@@ -21645,6 +21668,8 @@ CREATE INDEX index_ci_job_variables_on_job_id ON ci_job_variables USING btree (j ...@@ -21645,6 +21668,8 @@ CREATE INDEX index_ci_job_variables_on_job_id ON ci_job_variables USING btree (j
CREATE UNIQUE INDEX index_ci_job_variables_on_key_and_job_id ON ci_job_variables USING btree (key, job_id); CREATE UNIQUE INDEX index_ci_job_variables_on_key_and_job_id ON ci_job_variables USING btree (key, job_id);
CREATE UNIQUE INDEX index_ci_namespace_monthly_usages_on_namespace_id_and_date ON ci_namespace_monthly_usages USING btree (namespace_id, date);
CREATE INDEX index_ci_pipeline_artifacts_on_expire_at ON ci_pipeline_artifacts USING btree (expire_at); CREATE INDEX index_ci_pipeline_artifacts_on_expire_at ON ci_pipeline_artifacts USING btree (expire_at);
CREATE INDEX index_ci_pipeline_artifacts_on_pipeline_id ON ci_pipeline_artifacts USING btree (pipeline_id); CREATE INDEX index_ci_pipeline_artifacts_on_pipeline_id ON ci_pipeline_artifacts USING btree (pipeline_id);
# frozen_string_literal: true
module Ci
module Minutes
# Track usage of Shared Runners minutes at root namespace level.
# This class ensures that we keep 1 record per namespace per month.
class NamespaceMonthlyUsage < ApplicationRecord
self.table_name = "ci_namespace_monthly_usages"
belongs_to :namespace
scope :current_month, -> { where(date: Time.current.utc.beginning_of_month) }
# We should pretty much always use this method to access data for the current month
# since this will lazily create an entry if it doesn't exist.
# For example, on the 1st of each month, when we update the usage for a namespace,
# we will automatically generate new records and reset usage for the current month.
#
# Here we will also do any recalculation of additional minutes based on the
# previous month usage.
def self.find_or_create_current(namespace)
current_month.safe_find_or_create_by(namespace: namespace)
end
def self.increase_usage(namespace, amount)
return unless amount > 0
# The use of `update_counters` ensures we do a SQL update rather than
# incrementing the counter for the object in memory and then save it.
# This is better for concurrent updates.
update_counters(self.find_or_create_current(namespace), amount_used: amount)
end
end
end
end
...@@ -8,22 +8,30 @@ module Ci ...@@ -8,22 +8,30 @@ module Ci
return unless build.complete? return unless build.complete?
return unless build.duration&.positive? return unless build.duration&.positive?
count_projects_based_on_cost_factors(build) consumption = ::Gitlab::Ci::Minutes::BuildConsumption.new(build).amount
end
private return unless consumption > 0
def count_projects_based_on_cost_factors(build) consumption_in_seconds = consumption.minutes.to_i
cost_factor = build.runner.minutes_cost_factor(project.visibility_level) legacy_track_usage_of_monthly_minutes(consumption_in_seconds)
duration_with_cost_factor = (build.duration * cost_factor).to_i
track_usage_of_monthly_minutes(consumption)
end
return unless duration_with_cost_factor > 0 private
def legacy_track_usage_of_monthly_minutes(consumption)
ProjectStatistics.update_counters(project_statistics, ProjectStatistics.update_counters(project_statistics,
shared_runners_seconds: duration_with_cost_factor) shared_runners_seconds: consumption)
NamespaceStatistics.update_counters(namespace_statistics, NamespaceStatistics.update_counters(namespace_statistics,
shared_runners_seconds: duration_with_cost_factor) shared_runners_seconds: consumption)
end
def track_usage_of_monthly_minutes(consumption)
return unless Feature.enabled?(:ci_minutes_monthly_tracking, project, default_enabled: :yaml)
::Ci::Minutes::NamespaceMonthlyUsage.increase_usage(namespace, consumption)
end end
def namespace_statistics def namespace_statistics
......
---
name: ci_minutes_monthly_tracking
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/52915
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/300803
milestone: '13.9'
type: development
group: group::continuous integration
default_enabled: false
# frozen_string_literal: true
module Gitlab
module Ci
module Minutes
# Calculate the consumption of CI minutes based on a cost factor
# assigned to the involved Runner.
# The amount returned is a float so that internally we could track
# an accurate usage of minutes/credits.
class BuildConsumption
def initialize(build)
@build = build
end
def amount
(@build.duration.to_f / 60 * cost_factor).round(2)
end
private
def cost_factor
@build.runner.minutes_cost_factor(project_visibility_level)
end
def project_visibility_level
@build.project.visibility_level
end
end
end
end
end
# frozen_string_literal: true
FactoryBot.define do
factory :ci_namespace_monthly_usage, class: 'Ci::Minutes::NamespaceMonthlyUsage' do
amount_used { 100 }
namespace factory: :namespace
date { Time.current.utc.beginning_of_month }
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::Ci::Minutes::BuildConsumption do
using RSpec::Parameterized::TableSyntax
let(:consumption) { described_class.new(build) }
let(:build) { build_stubbed(:ci_build, runner: runner, project: project) }
let_it_be(:project) { create(:project) }
let_it_be(:runner) { create(:ci_runner, :instance) }
describe '#amount' do
subject { consumption.amount }
where(:duration, :visibility_level, :public_cost_factor, :private_cost_factor, :result) do
120 | Gitlab::VisibilityLevel::PRIVATE | 1.0 | 2.0 | 4.0
120 | Gitlab::VisibilityLevel::INTERNAL | 1.0 | 2.0 | 4.0
120 | Gitlab::VisibilityLevel::INTERNAL | 1.0 | 1.5 | 3.0
120 | Gitlab::VisibilityLevel::PUBLIC | 2.0 | 1.0 | 4.0
120 | Gitlab::VisibilityLevel::PUBLIC | 1.0 | 1.0 | 2.0
120 | Gitlab::VisibilityLevel::PUBLIC | 0.5 | 1.0 | 1.0
119 | Gitlab::VisibilityLevel::PUBLIC | 0.5 | 1.0 | 0.99
end
with_them do
before do
runner.update!(
public_projects_minutes_cost_factor: public_cost_factor,
private_projects_minutes_cost_factor: private_cost_factor)
project.update!(visibility_level: visibility_level)
allow(build).to receive(:duration).and_return(duration)
end
it 'returns the expected consumption' do
expect(subject).to eq(result)
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Ci::Minutes::NamespaceMonthlyUsage do
let_it_be(:namespace) { create(:namespace) }
describe 'unique index' do
before_all do
create(:ci_namespace_monthly_usage, namespace: namespace)
end
it 'raises unique index violation' do
expect { create(:ci_namespace_monthly_usage, namespace: namespace) }
.to raise_error { ActiveRecord::RecordNotUnique }
end
it 'does not raise exception if unique index is not violated' do
expect { create(:ci_namespace_monthly_usage, namespace: namespace, date: 1.month.ago.utc.beginning_of_month) }
.to change { described_class.count }.by(1)
end
end
describe '.find_or_create_current' do
subject { described_class.find_or_create_current(namespace) }
shared_examples 'creates usage record' do
it 'creates new record and resets minutes consumption' do
freeze_time do
expect { subject }.to change { described_class.count }.by(1)
expect(subject.amount_used).to eq(0)
expect(subject.namespace).to eq(namespace)
expect(subject.date).to eq(Time.current.beginning_of_month)
end
end
end
context 'when namespace usage does not exist' do
it_behaves_like 'creates usage record'
end
context 'when namespace usage exists for previous months' do
before do
create(:ci_namespace_monthly_usage, namespace: namespace, date: 2.months.ago.utc.beginning_of_month)
end
it_behaves_like 'creates usage record'
end
context 'when namespace usage exists for the current month' do
it 'returns the existing usage' do
freeze_time do
usage = create(:ci_namespace_monthly_usage, namespace: namespace)
expect(subject).to eq(usage)
end
end
end
context 'when a usage for another namespace exists for the current month' do
let!(:usage) { create(:ci_namespace_monthly_usage) }
it_behaves_like 'creates usage record'
end
end
describe '.increase_usage' do
subject { described_class.increase_usage(namespace, amount) }
context 'when usage for current month exists' do
let!(:usage) { create(:ci_namespace_monthly_usage, namespace: namespace, amount_used: 100.0) }
context 'when amount is greater than 0' do
let(:amount) { 10.5 }
it 'updates the current month usage' do
subject
expect(usage.reload.amount_used).to eq(110.5)
end
end
context 'when amount is less or equal to 0' do
let(:amount) { -2.0 }
it 'does not update the current month usage' do
subject
expect(usage.reload.amount_used).to eq(100.0)
end
end
end
context 'when usage for current month does not exist' do
let(:amount) { 17.0 }
it 'creates a new record for the current month and records the usage' do
expect { subject }.to change { described_class.count }.by(1)
current_usage = described_class.find_or_create_current(namespace)
expect(current_usage.amount_used).to eq(17.0)
end
end
end
end
...@@ -7,19 +7,31 @@ RSpec.describe Ci::Minutes::UpdateBuildMinutesService do ...@@ -7,19 +7,31 @@ RSpec.describe Ci::Minutes::UpdateBuildMinutesService do
let(:namespace) { create(:namespace, shared_runners_minutes_limit: 100) } let(:namespace) { create(:namespace, shared_runners_minutes_limit: 100) }
let(:project) { create(:project, :public, namespace: namespace) } let(:project) { create(:project, :public, namespace: namespace) }
let(:pipeline) { create(:ci_pipeline, project: project) } let(:pipeline) { create(:ci_pipeline, project: project) }
let(:build) do let(:build) do
create(:ci_build, :success, create(:ci_build, :success,
runner: runner, pipeline: pipeline, runner: runner, pipeline: pipeline,
started_at: 2.hours.ago, finished_at: 1.hour.ago) started_at: 2.hours.ago, finished_at: 1.hour.ago)
end end
let(:namespace_amount_used) { Ci::Minutes::NamespaceMonthlyUsage.find_or_create_current(namespace).amount_used }
subject { described_class.new(project, nil).execute(build) } subject { described_class.new(project, nil).execute(build) }
shared_examples 'new tracking matches legacy tracking' do
it 'stores the same information in both legacy and new tracking' do
subject
expect(namespace_amount_used)
.to eq((namespace.namespace_statistics.reload.shared_runners_seconds.to_f / 60).round(2))
end
end
context 'with shared runner' do context 'with shared runner' do
let(:cost_factor) { 2.0 } let(:cost_factor) { 2.0 }
let(:runner) { create(:ci_runner, :instance, public_projects_minutes_cost_factor: cost_factor) } let(:runner) { create(:ci_runner, :instance, public_projects_minutes_cost_factor: cost_factor) }
it "creates a statistics and sets duration with applied cost factor" do it 'creates a statistics and sets duration with applied cost factor' do
subject subject
expect(project.statistics.reload.shared_runners_seconds) expect(project.statistics.reload.shared_runners_seconds)
...@@ -29,20 +41,62 @@ RSpec.describe Ci::Minutes::UpdateBuildMinutesService do ...@@ -29,20 +41,62 @@ RSpec.describe Ci::Minutes::UpdateBuildMinutesService do
.to eq(build.duration.to_i * 2) .to eq(build.duration.to_i * 2)
end end
it 'tracks the usage on a monthly basis' do
subject
expect(namespace_amount_used).to eq((60 * 2).to_f)
end
it_behaves_like 'new tracking matches legacy tracking'
context 'when feature flag ci_minutes_monthly_tracking is disabled' do
before do
stub_feature_flags(ci_minutes_monthly_tracking: false)
end
it 'does not track usage on a monthly basis' do
expect(namespace_amount_used).to eq(0)
end
end
context 'when statistics are created' do context 'when statistics are created' do
let(:usage_in_seconds) { 100 }
let(:usage_in_minutes) { (100.to_f / 60).round(2) }
before do before do
project.statistics.update!(shared_runners_seconds: 100) project.statistics.update!(shared_runners_seconds: usage_in_seconds)
namespace.create_namespace_statistics(shared_runners_seconds: 100) namespace.create_namespace_statistics(shared_runners_seconds: usage_in_seconds)
create(:ci_namespace_monthly_usage, namespace: namespace, amount_used: usage_in_minutes)
end end
it "updates statistics and adds duration with applied cost factor" do it 'updates statistics and adds duration with applied cost factor' do
subject subject
expect(project.statistics.reload.shared_runners_seconds) expect(project.statistics.reload.shared_runners_seconds)
.to eq(100 + build.duration.to_i * 2) .to eq(usage_in_seconds + build.duration.to_i * 2)
expect(namespace.namespace_statistics.reload.shared_runners_seconds) expect(namespace.namespace_statistics.reload.shared_runners_seconds)
.to eq(100 + build.duration.to_i * 2) .to eq(usage_in_seconds + build.duration.to_i * 2)
end
it 'tracks the usage on a monthly basis' do
subject
expect(namespace_amount_used).to eq(usage_in_minutes + 60 * 2)
end
it_behaves_like 'new tracking matches legacy tracking'
context 'when feature flag ci_minutes_monthly_tracking is disabled' do
before do
stub_feature_flags(ci_minutes_monthly_tracking: false)
end
it 'does not track usage on a monthly basis' do
subject
expect(namespace_amount_used).to eq(usage_in_minutes)
end
end end
end end
...@@ -50,12 +104,27 @@ RSpec.describe Ci::Minutes::UpdateBuildMinutesService do ...@@ -50,12 +104,27 @@ RSpec.describe Ci::Minutes::UpdateBuildMinutesService do
let(:root_ancestor) { create(:group, shared_runners_minutes_limit: 100) } let(:root_ancestor) { create(:group, shared_runners_minutes_limit: 100) }
let(:namespace) { create(:group, parent: root_ancestor) } let(:namespace) { create(:group, parent: root_ancestor) }
let(:namespace_amount_used) { Ci::Minutes::NamespaceMonthlyUsage.find_or_create_current(root_ancestor).amount_used }
it 'creates a statistics in root group' do it 'creates a statistics in root group' do
subject subject
expect(root_ancestor.namespace_statistics.reload.shared_runners_seconds) expect(root_ancestor.namespace_statistics.reload.shared_runners_seconds)
.to eq(build.duration.to_i * 2) .to eq(build.duration.to_i * 2)
end end
it 'tracks the usage on a monthly basis' do
subject
expect(namespace_amount_used).to eq(60 * 2)
end
it 'stores the same information in both legacy and new tracking' do
subject
expect(namespace_amount_used)
.to eq((root_ancestor.namespace_statistics.reload.shared_runners_seconds.to_f / 60).round(2))
end
end end
context 'when cost factor has non-zero fractional part' do context 'when cost factor has non-zero fractional part' do
...@@ -76,11 +145,15 @@ RSpec.describe Ci::Minutes::UpdateBuildMinutesService do ...@@ -76,11 +145,15 @@ RSpec.describe Ci::Minutes::UpdateBuildMinutesService do
context 'for specific runner' do context 'for specific runner' do
let(:runner) { create(:ci_runner, :project) } let(:runner) { create(:ci_runner, :project) }
it "does not create statistics" do it 'does not create statistics' do
subject subject
expect(namespace.namespace_statistics).to be_nil expect(namespace.namespace_statistics).to be_nil
end end
it 'does not track monthly usage' do
expect { subject }.not_to change { Ci::Minutes::NamespaceMonthlyUsage.count }
end
end end
end end
end end
...@@ -26,6 +26,7 @@ RSpec.describe 'Database schema' do ...@@ -26,6 +26,7 @@ RSpec.describe 'Database schema' do
chat_names: %w[chat_id team_id user_id], chat_names: %w[chat_id team_id user_id],
chat_teams: %w[team_id], chat_teams: %w[team_id],
ci_builds: %w[erased_by_id runner_id trigger_request_id user_id], ci_builds: %w[erased_by_id runner_id trigger_request_id user_id],
ci_namespace_monthly_usages: %w[namespace_id],
ci_pipelines: %w[user_id], ci_pipelines: %w[user_id],
ci_runner_projects: %w[runner_id], ci_runner_projects: %w[runner_id],
ci_trigger_requests: %w[commit_id], ci_trigger_requests: %w[commit_id],
......
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