# frozen_string_literal: true

require 'spec_helper'

RSpec.describe Gitlab::Database::BackgroundMigration::BatchedJob, type: :model, feature_category: :database do
  it_behaves_like 'having unique enum values'

  it { is_expected.to be_a Gitlab::Database::SharedModel }

  specify do
    expect(described_class::TIMEOUT_EXCEPTIONS).to contain_exactly(
      ActiveRecord::StatementTimeout,
      ActiveRecord::ConnectionTimeoutError,
      ActiveRecord::AdapterTimeout,
      ActiveRecord::LockWaitTimeout,
      ActiveRecord::QueryCanceled
    )
  end

  describe 'validations' do
    subject { build(:batched_background_migration_job) }

    it { is_expected.to validate_numericality_of(:pause_ms).is_greater_than_or_equal_to(described_class::MINIMUM_PAUSE_MS) }
  end

  describe 'associations' do
    it { is_expected.to belong_to(:batched_migration).with_foreign_key(:batched_background_migration_id) }
    it { is_expected.to have_many(:batched_job_transition_logs).with_foreign_key(:batched_background_migration_job_id) }
  end

  describe 'state machine' do
    let_it_be(:job) { create(:batched_background_migration_job, :failed) }

    it { expect(described_class.state_machine.states.map(&:name)).to eql(%i[pending running failed succeeded]) }

    context 'when a job is running' do
      it 'logs the transition' do
        expect(Gitlab::AppLogger).to receive(:info).with(
          {
            batched_job_id: job.id,
            batched_migration_id: job.batched_background_migration_id,
            exception_class: nil,
            exception_message: nil,
            job_arguments: job.batched_migration.job_arguments,
            job_class_name: job.batched_migration.job_class_name,
            message: 'BatchedJob transition',
            new_state: :running,
            previous_state: :failed
          }
        )

        expect { job.run! }.to change(job, :started_at)
      end
    end

    context 'when a job succeed' do
      let(:job) { create(:batched_background_migration_job, :running) }

      it 'logs the transition' do
        expect(Gitlab::AppLogger).to receive(:info).with(
          {
            batched_job_id: job.id,
            batched_migration_id: job.batched_background_migration_id,
            exception_class: nil,
            exception_message: nil,
            job_arguments: job.batched_migration.job_arguments,
            job_class_name: job.batched_migration.job_class_name,
            message: 'BatchedJob transition',
            new_state: :succeeded,
            previous_state: :running
          }
        )

        job.succeed!
      end

      it 'updates the finished_at' do
        expect { job.succeed! }.to change(job, :finished_at).from(nil).to(Time)
      end

      it 'creates a new transition log' do
        job.succeed!

        transition_log = job.batched_job_transition_logs.first

        expect(transition_log.next_status).to eq('succeeded')
        expect(transition_log.exception_class).to be_nil
        expect(transition_log.exception_message).to be_nil
      end
    end

    context 'when a job fails the number of max times' do
      let(:max_times) { described_class::MAX_ATTEMPTS }
      let!(:job) { create(:batched_background_migration_job, :running, batch_size: 10, min_value: 6, max_value: 15, attempts: max_times) }

      context 'when job can be split' do
        let(:exception) { ActiveRecord::StatementTimeout.new('Timeout!') }

        before do
          allow_next_instance_of(Gitlab::BackgroundMigration::BatchingStrategies::PrimaryKeyBatchingStrategy) do |batch_class|
            allow(batch_class).to receive(:next_batch).and_return([6, 10])
          end
        end

        it 'splits the job into two retriable jobs' do
          expect { job.failure!(error: exception) }.to change { job.batched_migration.batched_jobs.retriable.count }.from(0).to(2)
        end
      end

      context 'when the job cannot be split' do
        let(:exception) { ActiveRecord::StatementTimeout.new('Timeout!') }
        let(:max_times) { described_class::MAX_ATTEMPTS }
        let!(:job) { create(:batched_background_migration_job, :running, batch_size: 50, sub_batch_size: 20, min_value: 6, max_value: 15, attempts: max_times) }
        let(:error_message) { 'Job cannot be split further' }
        let(:split_and_retry_exception) { Gitlab::Database::BackgroundMigration::SplitAndRetryError.new(error_message) }

        before do
          allow(job).to receive(:split_and_retry!).and_raise(split_and_retry_exception)
        end

        it 'does not split the job' do
          expect { job.failure!(error: exception) }.not_to change { job.batched_migration.batched_jobs.retriable.count }
        end

        it 'keeps the same job attributes' do
          expect { job.failure!(error: exception) }.not_to change { job }
        end

        it 'logs the error' do
          expect(Gitlab::AppLogger).to receive(:error).with(
            {
              batched_job_id: job.id,
              batched_migration_id: job.batched_background_migration_id,
              job_arguments: job.batched_migration.job_arguments,
              job_class_name: job.batched_migration.job_class_name,
              message: error_message
            }
          )

          job.failure!(error: exception)
        end
      end
    end

    context 'when a job fails' do
      let(:job) { create(:batched_background_migration_job, :running) }

      it 'logs the transition' do
        expect(Gitlab::AppLogger).to receive(:info).with(
          {
            batched_job_id: job.id,
            batched_migration_id: job.batched_background_migration_id,
            exception_class: RuntimeError,
            exception_message: 'error',
            job_arguments: job.batched_migration.job_arguments,
            job_class_name: job.batched_migration.job_class_name,
            message: 'BatchedJob transition',
            new_state: :failed,
            previous_state: :running
          }
        )

        job.failure!(error: RuntimeError.new('error'))
      end

      it 'tracks the exception' do
        expect(Gitlab::ErrorTracking).to receive(:track_exception).with(
          RuntimeError,
          {
            batched_job_id: job.id,
            job_arguments: job.batched_migration.job_arguments,
            job_class_name: job.batched_migration.job_class_name
          }
        )

        job.failure!(error: RuntimeError.new)
      end

      it 'updates the finished_at' do
        expect { job.failure! }.to change(job, :finished_at).from(nil).to(Time)
      end

      it 'creates a new transition log' do
        job.failure!(error: RuntimeError.new)

        transition_log = job.batched_job_transition_logs.first

        expect(transition_log.next_status).to eq('failed')
        expect(transition_log.exception_class).to eq('RuntimeError')
        expect(transition_log.exception_message).to eq('RuntimeError')
      end

      context 'with Sidekiq::Shutdown' do
        it 'decrements job attemts' do
          job.update!(attempts: 2)

          expect { job.failure!(error: Sidekiq::Shutdown.new) }.to change { job.reload.attempts }.by(-1)
        end

        it 'does not decrement job attemts if less than or equal to 1' do
          job.update!(attempts: 1)

          expect { job.failure!(error: Sidekiq::Shutdown.new) }.not_to change { job.reload.attempts }
        end

        it 'does not decrement job attemts if there are more than MAX_SIDEKIQ_SHUTDOWN_FAILURES' do
          job.update!(attempts: 2)

          stub_const("#{described_class}::MAX_SIDEKIQ_SHUTDOWN_FAILURES", 3)

          (described_class::MAX_SIDEKIQ_SHUTDOWN_FAILURES + 1).times do
            create(:batched_background_job_transition_log, :sidekiq_shutdown_failure, batched_job: job)
          end

          expect { job.failure!(error: Sidekiq::Shutdown.new) }.not_to change { job.reload.attempts }
        end
      end

      context 'with other exception' do
        it 'does not update job attemts' do
          job.update!(attempts: 2)

          expect { job.failure!(error: RuntimeError.new) }.not_to change { job.reload.attempts }
        end
      end
    end

    context 'when job fails during sub batch processing' do
      let(:args) { { error: ActiveRecord::StatementTimeout.new, from_sub_batch: true } }
      let(:attempts) { 0 }
      let(:failure) { job.failure!(**args) }
      let(:job) do
        create(:batched_background_migration_job, :running, batch_size: 20, sub_batch_size: 10, attempts: attempts)
      end

      context 'when sub batch size can be reduced in 25%' do
        it { expect { failure }.to change { job.sub_batch_size }.to 7 }
      end

      context 'when retries exceeds 2 attempts' do
        let(:attempts) { 3 }

        before do
          allow(job).to receive(:split_and_retry!)
        end

        it 'calls split_and_retry! once sub_batch_size cannot be decreased anymore' do
          failure

          expect(job).to have_received(:split_and_retry!).once
        end

        it { expect { failure }.not_to change { job.sub_batch_size } }
      end
    end
  end

  describe 'scopes' do
    let_it_be(:fixed_time) { Time.new(2021, 04, 27, 10, 00, 00, 00) }

    let_it_be(:pending_job) { create(:batched_background_migration_job, :pending, created_at: fixed_time - 2.days, updated_at: fixed_time) }
    let_it_be(:running_job) { create(:batched_background_migration_job, :running, created_at: fixed_time - 2.days, updated_at: fixed_time) }
    let_it_be(:stuck_job) { create(:batched_background_migration_job, :pending, created_at: fixed_time, updated_at: fixed_time - described_class::STUCK_JOBS_TIMEOUT) }
    let_it_be(:failed_job) { create(:batched_background_migration_job, :failed, created_at: fixed_time, attempts: 1) }
    let_it_be(:max_attempts_failed_job) { create(:batched_background_migration_job, :failed, created_at: fixed_time, attempts: described_class::MAX_ATTEMPTS) }

    before do
      travel_to fixed_time
    end

    describe '.except_succeeded' do
      it 'returns not succeeded jobs' do
        expect(described_class.except_succeeded).to contain_exactly(pending_job, running_job, stuck_job, failed_job, max_attempts_failed_job)
      end
    end

    describe '.active' do
      it 'returns active jobs' do
        expect(described_class.active).to contain_exactly(pending_job, running_job, stuck_job)
      end
    end

    describe '.stuck' do
      it 'returns stuck jobs' do
        expect(described_class.stuck).to contain_exactly(stuck_job)
      end
    end

    describe '.retriable' do
      it 'returns retriable jobs' do
        expect(described_class.retriable).to contain_exactly(failed_job, stuck_job)
      end
    end

    describe '.created_since' do
      it 'returns jobs since a given time' do
        expect(described_class.created_since(fixed_time)).to contain_exactly(stuck_job, failed_job, max_attempts_failed_job)
      end
    end

    describe '.blocked_by_max_attempts' do
      it 'returns blocked jobs' do
        expect(described_class.blocked_by_max_attempts).to contain_exactly(max_attempts_failed_job)
      end
    end
  end

  describe 'delegated batched_migration attributes' do
    let(:batched_job) { build(:batched_background_migration_job) }
    let(:batched_migration) { batched_job.batched_migration }

    describe '#migration_job_class' do
      it 'returns the migration job_class' do
        expect(batched_job.migration_job_class).to eq(batched_migration.job_class)
      end
    end

    describe '#migration_table_name' do
      it 'returns the migration table_name' do
        expect(batched_job.migration_table_name).to eq(batched_migration.table_name)
      end
    end

    describe '#migration_column_name' do
      it 'returns the migration column_name' do
        expect(batched_job.migration_column_name).to eq(batched_migration.column_name)
      end
    end

    describe '#migration_job_arguments' do
      it 'returns the migration job_arguments' do
        expect(batched_job.migration_job_arguments).to eq(batched_migration.job_arguments)
      end
    end

    describe '#migration_job_class_name' do
      it 'returns the migration job_class_name' do
        expect(batched_job.migration_job_class_name).to eq(batched_migration.job_class_name)
      end
    end
  end

  describe '.extract_transition_options' do
    let(:perform) { subject.class.extract_transition_options(args) }

    where(:args, :expected_result) do
      [
        [[], []],
        [[{ error: StandardError }], [StandardError, nil]],
        [[{ error: StandardError, from_sub_batch: true }], [StandardError, true]]
      ]
    end

    with_them do
      it 'matches expected keys and result' do
        expect(perform).to match_array(expected_result)
      end
    end
  end

  describe '#can_split?' do
    subject { job.can_split?(exception) }

    context 'when the number of attempts is greater than the limit and the batch_size is greater than the sub_batch_size' do
      let(:job) { create(:batched_background_migration_job, :failed, batch_size: 4, sub_batch_size: 2, attempts: described_class::MAX_ATTEMPTS + 1) }

      context 'when is a timeout exception' do
        let(:exception) { ActiveRecord::StatementTimeout.new }

        it { expect(subject).to be_truthy }
      end

      context 'when is a QueryCanceled exception' do
        let(:exception) { ActiveRecord::QueryCanceled.new }

        it { expect(subject).to be_truthy }
      end

      context 'when is not a timeout exception' do
        let(:exception) { RuntimeError.new }

        it { expect(subject).to be_falsey }
      end
    end

    context 'when the number of attempts is lower than the limit and the batch_size is greater than the sub_batch_size' do
      let(:job) { create(:batched_background_migration_job, :failed, batch_size: 4, sub_batch_size: 2, attempts: described_class::MAX_ATTEMPTS - 1) }

      context 'when is a timeout exception' do
        let(:exception) { ActiveRecord::StatementTimeout.new }

        it { expect(subject).to be_falsey }
      end

      context 'when is not a timeout exception' do
        let(:exception) { RuntimeError.new }

        it { expect(subject).to be_falsey }
      end
    end

    context 'when the batch_size is lower than the sub_batch_size' do
      let(:job) { create(:batched_background_migration_job, :failed, batch_size: 2, sub_batch_size: 4) }
      let(:exception) { ActiveRecord::StatementTimeout.new }

      it { expect(subject).to be_falsey }
    end

    context 'when the batch_size is 1' do
      let(:job) { create(:batched_background_migration_job, :failed, batch_size: 1) }
      let(:exception) { ActiveRecord::StatementTimeout.new }

      it { expect(subject).to be_falsey }
    end
  end

  describe '#can_reduce_sub_batch_size?' do
    let(:attempts) { 0 }
    let(:batch_size) { 10 }
    let(:sub_batch_size) { 6 }
    let(:job) do
      create(:batched_background_migration_job, attempts: attempts,
        batch_size: batch_size, sub_batch_size: sub_batch_size)
    end

    context 'when the number of attempts is lower than the limit and batch size are within boundaries' do
      let(:attempts) { 1 }

      it { expect(job.can_reduce_sub_batch_size?).to be(true) }
    end

    context 'when the number of attempts is lower than the limit and batch size are outside boundaries' do
      let(:batch_size) { 1 }

      it { expect(job.can_reduce_sub_batch_size?).to be(false) }
    end

    context 'when the number of attempts is greater than the limit and batch size are within boundaries' do
      let(:attempts) { 3 }

      it { expect(job.can_reduce_sub_batch_size?).to be(false) }
    end
  end

  describe '#time_efficiency' do
    subject { job.time_efficiency }

    let(:migration) { build(:batched_background_migration, interval: 120.seconds) }
    let(:job) { build(:batched_background_migration_job, :succeeded, batched_migration: migration) }

    context 'when job has not yet succeeded' do
      let(:job) { build(:batched_background_migration_job, :running) }

      it 'returns nil' do
        expect(subject).to be_nil
      end
    end

    context 'when finished_at is not set' do
      it 'returns nil' do
        job.started_at = Time.zone.now

        expect(subject).to be_nil
      end
    end

    context 'when started_at is not set' do
      it 'returns nil' do
        job.finished_at = Time.zone.now

        expect(subject).to be_nil
      end
    end

    context 'when job has finished' do
      it 'returns ratio of duration to interval, here: 0.5' do
        freeze_time do
          job.started_at = Time.zone.now - (migration.interval / 2)
          job.finished_at = Time.zone.now

          expect(subject).to eq(0.5)
        end
      end

      it 'returns ratio of duration to interval, here: 1' do
        freeze_time do
          job.started_at = Time.zone.now - migration.interval
          job.finished_at = Time.zone.now

          expect(subject).to eq(1)
        end
      end
    end
  end

  describe '#split_and_retry!' do
    let_it_be(:migration) { create(:batched_background_migration, table_name: :events) }
    let_it_be(:job) { create(:batched_background_migration_job, :failed, batched_migration: migration, batch_size: 10, min_value: 6, max_value: 15, attempts: 3) }
    let_it_be(:project) { create(:project) }

    before_all do
      (6..16).each do |id|
        create(:event, id: id, project: project)
      end
    end

    context 'when job can be split' do
      it 'sets the correct attributes' do
        expect { job.split_and_retry! }.to change { described_class.count }.by(1)

        expect(job).to have_attributes(
          min_value: 6,
          max_value: 10,
          batch_size: 5,
          status_name: :failed,
          attempts: 0,
          started_at: nil,
          finished_at: nil,
          metrics: {}
        )

        new_job = described_class.last

        expect(new_job).to have_attributes(
          batched_background_migration_id: job.batched_background_migration_id,
          min_value: 11,
          max_value: 15,
          batch_size: 5,
          status_name: :failed,
          attempts: 0,
          started_at: nil,
          finished_at: nil,
          metrics: {}
        )
        expect(new_job.created_at).not_to eq(job.created_at)
      end

      it 'splits the jobs into retriable jobs' do
        migration = job.batched_migration

        expect { job.split_and_retry! }.to change { migration.batched_jobs.retriable.count }.from(0).to(2)
      end
    end

    context 'when job is not failed' do
      let!(:job) { create(:batched_background_migration_job, :succeeded) }

      it 'raises an exception' do
        expect { job.split_and_retry! }.to raise_error 'Only failed jobs can be split'
      end
    end

    context 'when batch size is already 1' do
      let!(:job) { create(:batched_background_migration_job, :failed, batch_size: 1, attempts: 3) }

      it 'keeps the same batch size' do
        job.split_and_retry!

        expect(job.reload.batch_size).to eq 1
      end

      it 'resets the number of attempts' do
        job.split_and_retry!

        expect(job.attempts).to eq 0
      end
    end

    context 'when computed midpoint is larger than the max value of the batch' do
      before do
        Event.where(id: 6..12).delete_all
      end

      it 'lowers the batch size and resets the number of attempts' do
        expect { job.split_and_retry! }.not_to change { described_class.count }

        expect(job.batch_size).to eq(5)
        expect(job.attempts).to eq(0)
        expect(job.status_name).to eq(:failed)
      end
    end
  end

  describe '#reduce_sub_batch_size!' do
    let(:migration_batch_size) { 20 }
    let(:migration_sub_batch_size) { 10 }
    let(:job_batch_size) { 20 }
    let(:job_sub_batch_size) { 10 }
    let(:status) { :failed }

    let(:migration) do
      create(:batched_background_migration, :active, batch_size: migration_batch_size,
        sub_batch_size: migration_sub_batch_size)
    end

    let(:job) do
      create(:batched_background_migration_job, status, sub_batch_size: job_sub_batch_size,
        batch_size: job_batch_size, batched_migration: migration)
    end

    context 'when the job sub batch size can be reduced' do
      let(:expected_sub_batch_size) { 7 }

      it 'reduces sub batch size in 25%' do
        expect { job.reduce_sub_batch_size! }.to change { job.sub_batch_size }.to(expected_sub_batch_size)
      end

      it 'log the changes' do
        expect(Gitlab::AppLogger).to receive(:warn).with(
          message: 'Sub batch size reduced due to timeout',
          batched_job_id: job.id,
          sub_batch_size: job_sub_batch_size,
          reduced_sub_batch_size: expected_sub_batch_size,
          attempts: job.attempts,
          batched_migration_id: migration.id,
          job_class_name: job.migration_job_class_name,
          job_arguments: job.migration_job_arguments
        )

        job.reduce_sub_batch_size!
      end
    end

    context 'when reduced sub_batch_size is greater than sub_batch' do
      let(:job_batch_size) { 5 }

      it "doesn't allow sub_batch_size to greater than sub_batch" do
        expect { job.reduce_sub_batch_size! }.to change { job.sub_batch_size }.to 5
      end
    end

    context 'when sub_batch_size is already 1' do
      let(:job_sub_batch_size) { 1 }

      it "updates sub_batch_size to it's minimum value" do
        expect { job.reduce_sub_batch_size! }.not_to change { job.sub_batch_size }
      end
    end

    context 'when job has not failed' do
      let(:status) { :succeeded }
      let(:error) { Gitlab::Database::BackgroundMigration::ReduceSubBatchSizeError }

      it 'raises an exception' do
        expect { job.reduce_sub_batch_size! }.to raise_error(error)
      end
    end

    context 'when the amount to be reduced exceeds the threshold' do
      let(:migration_batch_size) { 150 }
      let(:migration_sub_batch_size) { 100 }
      let(:job_sub_batch_size) { 30 }

      it 'prevents sub batch size to be reduced' do
        expect { job.reduce_sub_batch_size! }.not_to change { job.sub_batch_size }
      end
    end
  end
end
