Skip to content

GitLab utilities

We have developed a number of utilities to help ease development:

MergeHash

Refer to merge_hash.rb:

  • Deep merges an array of hashes:

    Gitlab::Utils::MergeHash.merge(
      [{ hello: ["world"] },
       { hello: "Everyone" },
       { hello: { greetings: ['Bonjour', 'Hello', 'Hallo', 'Dzien dobry'] } },
        "Goodbye", "Hallo"]
    )

    Gives:

    [
      {
        hello:
          [
            "world",
            "Everyone",
            { greetings: ['Bonjour', 'Hello', 'Hallo', 'Dzien dobry'] }
          ]
      },
      "Goodbye"
    ]
  • Extracts all keys and values from a hash into an array:

    Gitlab::Utils::MergeHash.crush(
      { hello: "world", this: { crushes: ["an entire", "hash"] } }
    )

    Gives:

    [:hello, "world", :this, :crushes, "an entire", "hash"]

Override

Refer to override.rb:

  • This utility can help you check if one method would override another or not. It is the same concept as Java's @Override annotation or Scala's override keyword. However, we only run this check when ENV['STATIC_VERIFICATION'] is set to avoid production runtime overhead. This is useful for checking:

    • If you have typos in overriding methods.

    • If you renamed the overridden methods, which make the original override methods irrelevant.

      Here's a simple example:

      class Base
        def execute
        end
      end
      
      class Derived < Base
        extend ::Gitlab::Utils::Override
      
        override :execute # Override check happens here
        def execute
        end
      end

      This also works on modules:

      module Extension
        extend ::Gitlab::Utils::Override
      
        override :execute # Modules do not check this immediately
        def execute
        end
      end
      
      class Derived < Base
        prepend Extension # Override check happens here, not in the module
      end

      Note that the check only happens when either:

      • The overriding method is defined in a class, or:
      • The overriding method is defined in a module, and it's prepended to a class or a module.

      Because only a class or prepended module can actually override a method. Including or extending a module into another cannot override anything.

Interactions with ActiveSupport::Concern, prepend, and class_methods

When you use ActiveSupport::Concern that includes class methods, you do not get expected results because ActiveSupport::Concern doesn't work like a regular Ruby module.

Since we already have Prependable as a patch for ActiveSupport::Concern to enable prepend, it has consequences with how it would interact with override and class_methods. As a workaround, extend ClassMethods into the defining Prependable module.

This allows us to use override to verify class_methods used in the context mentioned above. This workaround only applies when we run the verification, not when running the application itself.

Here are example code blocks that demonstrate the effect of this workaround: following codes:

module Base
  extend ActiveSupport::Concern

  class_methods do
    def f
    end
  end
end

module Derived
  include Base
end

# Without the workaround
Base.f    # => NoMethodError
Derived.f # => nil

# With the workaround
Base.f    # => nil
Derived.f # => nil

StrongMemoize

Refer to strong_memoize.rb:

  • Memoize the value even if it is nil or false.

    We often do @value ||= compute. However, this doesn't work well if compute might eventually give nil and you don't want to compute again. Instead you could use defined? to check if the value is set or not. It's tedious to write such pattern, and StrongMemoize would help you use such pattern.

    Instead of writing patterns like this:

    class Find
      def result
        return @result if defined?(@result)
    
        @result = search
      end
    end

    You could write it like:

    class Find
      include Gitlab::Utils::StrongMemoize
    
      def result
        search
      end
      strong_memoize_attr :result
    
      def enabled?
        Feature.enabled?(:some_feature)
      end
      strong_memoize_attr :enabled?
    end

    Using strong_memoize_attr on methods with parameters is not supported. It does not work when combined with override and might memoize wrong results.

    Use strong_memoize_with instead.

    # bad
    def expensive_method(arg)
      # ...
    end
    strong_memoize_attr :expensive_method
    
    # good
    def expensive_method(arg)
      strong_memoize_with(:expensive_method, arg) do
        # ...
      end
    end

    There's also strong_memoize_with to help memoize methods that take arguments. This should be used for methods that have a low number of possible values as arguments or with consistent repeating arguments in a loop.

    class Find
      include Gitlab::Utils::StrongMemoize
    
      def result(basic: true)
        strong_memoize_with(:result, basic) do
          search(basic)
        end
      end
    end
  • Clear memoization

    class Find
      include Gitlab::Utils::StrongMemoize
    end
    
    Find.new.clear_memoization(:result)

RequestCache

Refer to request_cache.rb.

This module provides a simple way to cache values in RequestStore, and the cache key would be based on the class name, method name, optionally customized instance level values, optionally customized method level values, and optional method arguments.

A simple example that only uses the instance level customised values is:

class UserAccess
  extend Gitlab::Cache::RequestCache

  request_cache_key do
    [user&.id, project&.id]
  end

  request_cache def can_push_to_branch?(ref)
    # ...
  end
end

This way, the result of can_push_to_branch? would be cached in RequestStore.store based on the cache key. If RequestStore is not currently active, then it would be stored in a hash, and saved in an instance variable so the cache logic would be the same.

We can also set different strategies for different methods:

class Commit
  extend Gitlab::Cache::RequestCache

  def author
    User.find_by_any_email(author_email)
  end
  request_cache(:author) { author_email }
end

ReactiveCaching

Read the documentation on ReactiveCaching.

CircuitBreaker

The Gitlab::CircuitBreaker can be wrapped around any class that needs to run code with circuit breaker protection. It provides a run_with_circuit method that wraps a code block with circuit breaker functionality, which helps prevent cascading failures and improves system resilience. For more information about the circuit breaker pattern, see:

Use CircuitBreaker

To use the CircuitBreaker wrapper:

class MyService
  def call_external_service
    Gitlab::CircuitBreaker.run_with_circuit('ServiceName') do
      # Code that interacts with external service goes here

      raise Gitlab::CircuitBreaker::InternalServerError # if there is an issue
    end
  end
end

The call_external_service method is an example method that interacts with an external service. By wrapping the code that interacts with the external service with run_with_circuit, the method is executed within the circuit breaker.

The method should raise an InternalServerError error, which will be counted towards the error threshold if raised during the execution of the code block. The circuit breaker tracks the number of errors and the rate of requests, and opens the circuit if it reaches the configured error threshold or volume threshold. If the circuit is open, subsequent requests fail fast without executing the code block, and the circuit breaker periodically allows a small number of requests through to test the service's availability before closing the circuit again.

Configuration

You need to specify a service name for each unique circuit that is used as the cache key. This should be a CamelCase string which identifies the circuit.

The circuit breaker has defaults that can be overridden per circuit, for example:

Gitlab::CircuitBreaker.run_with_circuit('ServiceName', options = { volume_threshold: 5 }) do
  ...
end

The defaults are:

  • exceptions: [Gitlab::CircuitBreaker::InternalServerError]
  • error_threshold: 50
  • volume_threshold: 10
  • sleep_window: 90
  • time_window: 60