From 64ce9ab608be99b8d9da619dcfc191fe0b0ea01c Mon Sep 17 00:00:00 2001
From: Sylvester Chin <schin@gitlab.com>
Date: Thu, 18 Jul 2024 08:20:32 +0000
Subject: [PATCH] Guard PATs `last_used_at` updates with exclusive lease

---
 .../last_used_service.rb                      | 30 +++++++++++-
 .../use_lease_for_pat_last_used_update.yml    |  9 ++++
 .../last_used_service_spec.rb                 | 46 +++++++++++++++++++
 3 files changed, 83 insertions(+), 2 deletions(-)
 create mode 100644 config/feature_flags/gitlab_com_derisk/use_lease_for_pat_last_used_update.yml

diff --git a/app/services/personal_access_tokens/last_used_service.rb b/app/services/personal_access_tokens/last_used_service.rb
index f9a515a50795..3e8da55bc950 100644
--- a/app/services/personal_access_tokens/last_used_service.rb
+++ b/app/services/personal_access_tokens/last_used_service.rb
@@ -2,6 +2,10 @@
 
 module PersonalAccessTokens
   class LastUsedService
+    include ExclusiveLeaseGuard
+
+    LEASE_TIMEOUT = 60.seconds.to_i
+
     def initialize(personal_access_token)
       @personal_access_token = personal_access_token
     end
@@ -14,13 +18,35 @@ def execute
       # would be updated when using #touch).
       return unless update?
 
-      ::Gitlab::Database::LoadBalancing::Session.without_sticky_writes do
-        @personal_access_token.update_column(:last_used_at, Time.zone.now)
+      with_lease do
+        ::Gitlab::Database::LoadBalancing::Session.without_sticky_writes do
+          @personal_access_token.update_column(:last_used_at, Time.zone.now)
+        end
       end
     end
 
     private
 
+    def lease_timeout
+      LEASE_TIMEOUT
+    end
+
+    def lease_key
+      @lease_key ||= "pat:last_used_update_lock:#{@personal_access_token.id}"
+    end
+
+    def with_lease
+      return yield unless Feature.enabled?(
+        :use_lease_for_pat_last_used_update,
+        Feature.current_request,
+        type: :gitlab_com_derisk
+      )
+
+      try_obtain_lease do
+        yield
+      end
+    end
+
     def update?
       return false if ::Gitlab::Database.read_only?
 
diff --git a/config/feature_flags/gitlab_com_derisk/use_lease_for_pat_last_used_update.yml b/config/feature_flags/gitlab_com_derisk/use_lease_for_pat_last_used_update.yml
new file mode 100644
index 000000000000..349e999bc69e
--- /dev/null
+++ b/config/feature_flags/gitlab_com_derisk/use_lease_for_pat_last_used_update.yml
@@ -0,0 +1,9 @@
+---
+name: use_lease_for_pat_last_used_update
+feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/468851
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/158577
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/471276
+milestone: '17.2'
+group: group::scalability
+type: gitlab_com_derisk
+default_enabled: false
diff --git a/spec/services/personal_access_tokens/last_used_service_spec.rb b/spec/services/personal_access_tokens/last_used_service_spec.rb
index f450a6b01257..22c52d6a5af8 100644
--- a/spec/services/personal_access_tokens/last_used_service_spec.rb
+++ b/spec/services/personal_access_tokens/last_used_service_spec.rb
@@ -3,6 +3,8 @@
 require 'spec_helper'
 
 RSpec.describe PersonalAccessTokens::LastUsedService, feature_category: :system_access do
+  include ExclusiveLeaseHelpers
+
   describe '#execute' do
     subject { described_class.new(personal_access_token).execute }
 
@@ -13,12 +15,56 @@
         expect { subject }.to change { personal_access_token.last_used_at }
       end
 
+      it 'obtains an exclusive lease before updating' do
+        Gitlab::Redis::SharedState.with do |redis|
+          expect(redis).to receive(:set).with(
+            "#{Gitlab::ExclusiveLease::PREFIX}:pat:last_used_update_lock:#{personal_access_token.id}",
+            anything,
+            nx: true,
+            ex: described_class::LEASE_TIMEOUT
+          ).and_call_original
+        end
+
+        expect { subject }.to change { personal_access_token.last_used_at }
+      end
+
       it 'does not run on read-only GitLab instances' do
         allow(::Gitlab::Database).to receive(:read_only?).and_return(true)
 
         expect { subject }.not_to change { personal_access_token.last_used_at }
       end
 
+      context 'when lease is already acquired by another process' do
+        let(:lease_key) { "pat:last_used_update_lock:#{personal_access_token.id}" }
+
+        before do
+          stub_exclusive_lease_taken(lease_key, timeout: described_class::LEASE_TIMEOUT)
+        end
+
+        it 'does not update last_used_at' do
+          expect { subject }.not_to change { personal_access_token.last_used_at }
+        end
+      end
+
+      context 'when use_lease_for_pat_last_used_update flag is disabled' do
+        before do
+          stub_feature_flags(use_lease_for_pat_last_used_update: false)
+        end
+
+        it 'does not obtain an exclusive lease before updating' do
+          Gitlab::Redis::SharedState.with do |redis|
+            expect(redis).not_to receive(:set).with(
+              "#{Gitlab::ExclusiveLease::PREFIX}:pat:last_used_update_lock:#{personal_access_token.id}",
+              anything,
+              nx: true,
+              ex: described_class::LEASE_TIMEOUT
+            )
+          end
+
+          expect { subject }.to change { personal_access_token.last_used_at }
+        end
+      end
+
       context 'when database load balancing is configured' do
         let!(:service) { described_class.new(personal_access_token) }
 
-- 
GitLab