summaryrefslogtreecommitdiff
path: root/lib/gitlab/graphql/calls_gitaly/field_extension.rb
blob: 32530b47ce3737e9a0a57ebba92edfbbd7e957c5 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
# frozen_string_literal: true

module Gitlab
  module Graphql
    module CallsGitaly
      # Check if any `calls_gitaly: true` declarations need to be added
      #
      # See BaseField: this extension is not applied if the field does not
      # need it (i.e. it has a constant complexity or knows that it calls
      # gitaly)
      class FieldExtension < ::GraphQL::Schema::FieldExtension
        include Laziness

        def resolve(object:, arguments:, **rest)
          yield(object, arguments, [current_gitaly_call_count, accounted_for])
        end

        def after_resolve(value:, memo:, **rest)
          (value, count) = value_with_count(value, memo)
          calls_gitaly_check(count)
          accounted_for(count)

          value
        end

        private

        # Resolutions are not nested nicely (due to laziness), so we have to
        # know not just how many calls were made before resolution started, but
        # also how many were accounted for by fields with the correct settings
        # in between.
        #
        # e.g. the following is not just plausible, but common:
        #
        #   enter A.user (lazy)
        #   enter A.x
        #   leave A.x
        #   enter A.calls_gitaly
        #   leave A.calls_gitaly (accounts for 1 call)
        #   leave A.user
        #
        # In this circumstance we need to mark the calls made by A.calls_gitaly
        # as accounted for, even though they were made after we yielded
        # in A.user
        def value_with_count(value, (previous_count, previous_accounted_for))
          newly_accounted_for = accounted_for - previous_accounted_for
          value = force(value)
          count = [current_gitaly_call_count - (previous_count + newly_accounted_for), 0].max

          [value, count]
        end

        def current_gitaly_call_count
          Gitlab::GitalyClient.get_request_count || 0
        end

        def calls_gitaly_check(calls)
          return if calls < 1 || field.may_call_gitaly?

          error = RuntimeError.new(<<~ERROR)
            #{field_name} unexpectedly calls Gitaly!

            Please either specify a constant complexity or add `calls_gitaly: true`
            to the field declaration
          ERROR
          Gitlab::ErrorTracking.track_and_raise_for_dev_exception(error)
        end

        def accounted_for(count = nil)
          return 0 unless Gitlab::SafeRequestStore.active?

          Gitlab::SafeRequestStore["graphql_gitaly_accounted_for"] ||= 0

          if count.nil?
            Gitlab::SafeRequestStore["graphql_gitaly_accounted_for"]
          else
            Gitlab::SafeRequestStore["graphql_gitaly_accounted_for"] += count
          end
        end

        def field_name
          "#{field.owner.graphql_name}.#{field.graphql_name}"
        end
      end
    end
  end
end