1# frozen_string_literal: true
2
3# This class is not backed by a table in the main database.
4# It loads the latest Pipeline for the HEAD of a repository, and caches that
5# in Redis.
6module Gitlab
7  module Cache
8    module Ci
9      class ProjectPipelineStatus
10        include Gitlab::Utils::StrongMemoize
11
12        attr_accessor :sha, :status, :ref, :project, :loaded
13
14        def self.load_for_project(project)
15          new(project).tap do |status|
16            status.load_status
17          end
18        end
19
20        def self.load_in_batch_for_projects(projects)
21          projects.each do |project|
22            project.pipeline_status = new(project)
23            project.pipeline_status.load_status
24          end
25        end
26
27        def self.update_for_pipeline(pipeline)
28          pipeline_info = {
29            sha: pipeline.sha,
30            status: pipeline.status,
31            ref: pipeline.ref
32          }
33
34          new(pipeline.project, pipeline_info: pipeline_info)
35            .store_in_cache_if_needed
36        end
37
38        def initialize(project, pipeline_info: {}, loaded_from_cache: nil)
39          @project = project
40          @sha = pipeline_info[:sha]
41          @ref = pipeline_info[:ref]
42          @status = pipeline_info[:status]
43          @loaded = loaded_from_cache
44        end
45
46        def has_status?
47          loaded? && sha.present? && status.present?
48        end
49
50        def load_status
51          return if loaded?
52
53          if has_cache?
54            load_from_cache
55          else
56            load_from_project
57            store_in_cache
58          end
59
60          self.loaded = true
61        rescue GRPC::Unavailable, GRPC::DeadlineExceeded => e
62          # Handle Gitaly connection issues gracefully
63          Gitlab::ErrorTracking
64            .track_exception(e, project_id: project.id)
65        end
66
67        def load_from_project
68          return unless commit
69
70          self.sha = commit.sha
71          self.status = commit.status
72          self.ref = project.repository.root_ref
73        end
74
75        # We only cache the status for the HEAD commit of a project
76        # This status is rendered in project lists
77        def store_in_cache_if_needed
78          return delete_from_cache unless commit
79          return unless sha
80          return unless ref
81
82          if commit.sha == sha && project.repository.root_ref == ref
83            store_in_cache
84          end
85        end
86
87        def load_from_cache
88          Gitlab::Redis::Cache.with do |redis|
89            self.sha, self.status, self.ref = redis.hmget(cache_key, :sha, :status, :ref)
90
91            self.status = nil if self.status.empty?
92          end
93        end
94
95        def store_in_cache
96          Gitlab::Redis::Cache.with do |redis|
97            redis.mapped_hmset(cache_key, { sha: sha, status: status, ref: ref })
98          end
99        end
100
101        def delete_from_cache
102          Gitlab::Redis::Cache.with do |redis|
103            redis.del(cache_key)
104          end
105        end
106
107        def has_cache?
108          return self.loaded unless self.loaded.nil?
109
110          Gitlab::Redis::Cache.with do |redis|
111            redis.exists(cache_key)
112          end
113        end
114
115        def loaded?
116          self.loaded
117        end
118
119        def cache_key
120          "#{Gitlab::Redis::Cache::CACHE_NAMESPACE}:project:#{project.id}:pipeline_status"
121        end
122
123        def commit
124          strong_memoize(:commit) do
125            project.commit
126          end
127        end
128      end
129    end
130  end
131end
132