# frozen_string_literal: true

require 'spec_helper'

RSpec.describe Gitlab::Database::WithLockRetries, feature_category: :database do
  let(:env) { {} }
  let(:logger) { Gitlab::Database::WithLockRetries::NULL_LOGGER }
  let(:subject) { described_class.new(connection: connection, env: env, logger: logger, allow_savepoints: allow_savepoints, timing_configuration: timing_configuration) }
  let(:allow_savepoints) { true }
  let(:connection) { ActiveRecord::Base.retrieve_connection }

  let(:timing_configuration) do
    [
      [1.second, 1.second],
      [1.second, 1.second],
      [1.second, 1.second],
      [1.second, 1.second],
      [1.second, 1.second]
    ]
  end

  describe '#run' do
    it 'requires block' do
      expect { subject.run }.to raise_error(StandardError, 'no block given')
    end

    context 'when DISABLE_LOCK_RETRIES is set' do
      let(:env) { { 'DISABLE_LOCK_RETRIES' => 'true' } }

      it 'executes the passed block without retrying' do
        object = double

        expect(object).to receive(:method).once

        subject.run { object.method }
      end
    end

    context 'when lock retry is enabled', :delete do
      let(:lock_fiber) do
        Fiber.new do
          # Initiating a separate DB connection for the lock
          conn = ActiveRecord::Base.connection_pool.checkout

          conn.transaction do
            conn.execute("LOCK TABLE #{Project.table_name} in exclusive mode")

            Fiber.yield
          end
          # Releasing the connection we requested
          ActiveRecord::Base.connection_pool.checkin(conn)
        end
      end

      before do
        lock_fiber.resume # start the transaction and lock the table
      end

      after do
        lock_fiber.resume if lock_fiber.alive?
      end

      context 'lock_fiber' do
        it 'acquires lock successfully' do
          check_exclusive_lock_query = <<~QUERY
            SELECT 1
            FROM pg_locks l
            JOIN pg_class t ON l.relation = t.oid
            WHERE t.relkind = 'r' AND l.mode = 'ExclusiveLock' AND t.relname = '#{Project.table_name}'
          QUERY

          expect(connection.execute(check_exclusive_lock_query).to_a).to be_present
        end
      end

      shared_examples 'retriable exclusive lock on `projects`' do
        it 'succeeds executing the given block' do
          lock_attempts = 0
          lock_acquired = false

          # the actual number of attempts to run_block_with_lock_timeout can never exceed the number of
          # timings_configurations, so here we limit the retry_count if it exceeds that value
          #
          # also, there is no call to sleep after the final attempt, which is why it will always be one less
          expected_runs_with_timeout = [retry_count, timing_configuration.size].min
          expect(subject).to receive(:sleep).exactly(expected_runs_with_timeout - 1).times

          expect(subject).to receive(:run_block_with_lock_timeout).exactly(expected_runs_with_timeout).times.and_wrap_original do |method|
            lock_fiber.resume if lock_attempts == retry_count

            method.call
          end

          subject.run do
            lock_attempts += 1

            if lock_attempts == retry_count # we reached the last retry iteration, if we kill the thread, the last try (no lock_timeout) will succeed
              lock_fiber.resume
            end

            connection.transaction do
              connection.execute("LOCK TABLE #{Project.table_name} in exclusive mode")
              lock_acquired = true
            end
          end

          expect(lock_attempts).to eq(retry_count)
          expect(lock_acquired).to eq(true)
        end
      end

      context 'after 3 iterations' do
        it_behaves_like 'retriable exclusive lock on `projects`' do
          let(:retry_count) { 4 }
        end

        context 'setting the idle transaction timeout' do
          context 'when there is no outer transaction: disable_ddl_transaction! is set in the migration' do
            it 'does not disable the idle transaction timeout' do
              allow(connection).to receive(:transaction_open?).and_return(false)
              allow(subject).to receive(:run_block_with_lock_timeout).once.and_raise(ActiveRecord::LockWaitTimeout)
              allow(subject).to receive(:run_block_with_lock_timeout).once

              expect(subject).not_to receive(:disable_idle_in_transaction_timeout)

              subject.run {}
            end
          end

          context 'when there is outer transaction: disable_ddl_transaction! is not set in the migration' do
            it 'disables the idle transaction timeout so the code can sleep and retry' do
              allow(connection).to receive(:transaction_open?).and_return(true)

              n = 0
              allow(subject).to receive(:run_block_with_lock_timeout).twice do
                n += 1
                raise(ActiveRecord::LockWaitTimeout) if n == 1
              end

              expect(subject).to receive(:disable_idle_in_transaction_timeout).once

              subject.run {}
            end
          end
        end
      end

      context 'after the retries are exhausted' do
        let(:timing_configuration) do
          [
            [1.second, 1.second]
          ]
        end

        context 'when there is no outer transaction: disable_ddl_transaction! is set in the migration' do
          it 'does not disable the lock_timeout' do
            allow(connection).to receive(:transaction_open?).and_return(false)
            allow(subject).to receive(:run_block_with_lock_timeout).once.and_raise(ActiveRecord::LockWaitTimeout)

            expect(subject).not_to receive(:disable_lock_timeout)

            subject.run {}
          end
        end

        context 'when there is outer transaction: disable_ddl_transaction! is not set in the migration' do
          it 'disables the lock_timeout' do
            allow(connection).to receive(:transaction_open?).and_return(true)
            allow(subject).to receive(:run_block_with_lock_timeout).once.and_raise(ActiveRecord::LockWaitTimeout)

            expect(subject).to receive(:disable_lock_timeout)

            subject.run {}
          end
        end
      end

      context 'after the retries, without setting lock_timeout' do
        let(:retry_count) { timing_configuration.size + 1 }

        it_behaves_like 'retriable exclusive lock on `projects`' do
          before do
            expect(subject).to receive(:run_block_without_lock_timeout).and_call_original
          end
        end
      end

      context 'after the retries, when requested to raise an error' do
        let(:expected_attempts_with_timeout) { timing_configuration.size }
        let(:retry_count) { timing_configuration.size + 1 }

        it 'raises an error instead of waiting indefinitely for the lock' do
          lock_attempts = 0
          lock_acquired = false

          expect(subject).to receive(:sleep).exactly(expected_attempts_with_timeout - 1).times
          expect(subject).to receive(:run_block_with_lock_timeout).exactly(expected_attempts_with_timeout).times.and_call_original

          expect do
            subject.run(raise_on_exhaustion: true) do
              lock_attempts += 1

              connection.transaction do
                connection.execute("LOCK TABLE #{Project.table_name} in exclusive mode")
                lock_acquired = true
              end
            end
          end.to raise_error(described_class::AttemptsExhaustedError)

          expect(lock_attempts).to eq(retry_count - 1)
          expect(lock_acquired).to eq(false)
        end
      end

      context 'when statement timeout is reached' do
        it 'raises QueryCanceled error' do
          lock_acquired = false

          connection.transaction do
            connection.execute("SET LOCAL statement_timeout='100ms'")

            expect do
              subject.run do
                connection.execute("SELECT 1 FROM pg_sleep(0.11)") # 110ms
                lock_acquired = true
              end
            end.to raise_error(ActiveRecord::QueryCanceled)
          end

          expect(lock_acquired).to eq(false)
        end
      end
    end
  end

  context 'restore local database variables' do
    it do
      expect { subject.run {} }.not_to change { connection.execute("SHOW lock_timeout").to_a }
    end

    it do
      expect { subject.run {} }.not_to change { connection.execute("SHOW idle_in_transaction_session_timeout").to_a }
    end
  end

  context 'casting durations correctly' do
    let(:timing_configuration) { [[0.015.seconds, 0.025.seconds], [0.015.seconds, 0.025.seconds]] } # 15ms, 25ms

    it 'executes `SET LOCAL lock_timeout` using the configured timeout value in milliseconds' do
      expect(connection).to receive(:execute).with("RESET idle_in_transaction_session_timeout; RESET lock_timeout").and_call_original
      expect(connection).to receive(:create_savepoint).with('active_record_1')
      expect(connection).to receive(:execute).with("SET LOCAL lock_timeout TO '15ms'").and_call_original
      expect(connection).to receive(:release_savepoint).with('active_record_1')

      subject.run {}
    end

    it 'calls `sleep` after the first iteration fails, using the configured sleep time' do
      expect(subject).to receive(:run_block_with_lock_timeout).and_raise(ActiveRecord::LockWaitTimeout).twice
      expect(subject).to receive(:sleep).with(0.025)

      subject.run {}
    end
  end

  context 'Stop using subtransactions - allow_savepoints: false' do
    let(:allow_savepoints) { false }

    it 'prevents running inside already open transaction' do
      allow(connection).to receive(:transaction_open?).and_return(true)

      expect { subject.run {} }.to raise_error(/should not run inside already open transaction/)
    end

    it 'does not raise the error if not inside open transaction' do
      allow(connection).to receive(:transaction_open?).and_return(false)

      expect { subject.run {} }.not_to raise_error
    end
  end
end
