From ae2ffcfc220e668cfa2e2cdd1a378de6ca0b0e5a Mon Sep 17 00:00:00 2001
From: Quang-Minh Nguyen <qmnguyen@gitlab.com>
Date: Wed, 28 Apr 2021 09:36:15 +0000
Subject: [PATCH] Implement Sidekiq queue re-routing in the application

---
 app/workers/concerns/application_worker.rb    |  13 +-
 app/workers/concerns/worker_attributes.rb     |  26 +--
 ...-queue-re-routing-in-the-application-s.yml |   5 +
 config/gitlab.yml.example                     |   6 +
 config/initializers/1_settings.rb             |   1 +
 lib/gitlab/class_attributes.rb                |  16 ++
 lib/gitlab/sidekiq_config/worker_router.rb    | 107 +++++++++
 spec/lib/gitlab/class_attributes_spec.rb      |  62 +++--
 .../sidekiq_config/worker_router_spec.rb      | 212 ++++++++++++++++++
 .../concerns/application_worker_spec.rb       |  95 +++++++-
 10 files changed, 498 insertions(+), 45 deletions(-)
 create mode 100644 changelogs/unreleased/qmnguyen0711-1016-implement-sidekiq-queue-re-routing-in-the-application-s.yml
 create mode 100644 lib/gitlab/sidekiq_config/worker_router.rb
 create mode 100644 spec/lib/gitlab/sidekiq_config/worker_router_spec.rb

diff --git a/app/workers/concerns/application_worker.rb b/app/workers/concerns/application_worker.rb
index 0de26e2763110..843be4896a366 100644
--- a/app/workers/concerns/application_worker.rb
+++ b/app/workers/concerns/application_worker.rb
@@ -16,6 +16,7 @@ module ApplicationWorker
 
   included do
     set_queue
+    after_set_class_attribute { set_queue }
 
     def structured_payload(payload = {})
       context = Gitlab::ApplicationContext.current.merge(
@@ -47,22 +48,14 @@ def logging_extras
   class_methods do
     def inherited(subclass)
       subclass.set_queue
+      subclass.after_set_class_attribute { subclass.set_queue }
     end
 
     def set_queue
-      queue_name = [queue_namespace, base_queue_name].compact.join(':')
-
+      queue_name = ::Gitlab::SidekiqConfig::WorkerRouter.global.route(self)
       sidekiq_options queue: queue_name # rubocop:disable Cop/SidekiqOptionsQueue
     end
 
-    def base_queue_name
-      name
-        .sub(/\AGitlab::/, '')
-        .sub(/Worker\z/, '')
-        .underscore
-        .tr('/', '_')
-    end
-
     def queue_namespace(new_namespace = nil)
       if new_namespace
         sidekiq_options queue_namespace: new_namespace
diff --git a/app/workers/concerns/worker_attributes.rb b/app/workers/concerns/worker_attributes.rb
index 6f99fd089aca4..6dee94026913c 100644
--- a/app/workers/concerns/worker_attributes.rb
+++ b/app/workers/concerns/worker_attributes.rb
@@ -36,13 +36,13 @@ module WorkerAttributes
     def feature_category(value, *extras)
       raise "Invalid category. Use `feature_category_not_owned!` to mark a worker as not owned" if value == :not_owned
 
-      class_attributes[:feature_category] = value
+      set_class_attribute(:feature_category, value)
     end
 
     # Special case: mark this work as not associated with a feature category
     # this should be used for cross-cutting concerns, such as mailer workers.
     def feature_category_not_owned!
-      class_attributes[:feature_category] = :not_owned
+      set_class_attribute(:feature_category, :not_owned)
     end
 
     def get_feature_category
@@ -64,7 +64,7 @@ def feature_category_not_owned?
     def urgency(urgency)
       raise "Invalid urgency: #{urgency}" unless VALID_URGENCIES.include?(urgency)
 
-      class_attributes[:urgency] = urgency
+      set_class_attribute(:urgency, urgency)
     end
 
     def get_urgency
@@ -75,8 +75,8 @@ def data_consistency(data_consistency, feature_flag: nil)
       raise ArgumentError, "Invalid data consistency: #{data_consistency}" unless VALID_DATA_CONSISTENCIES.include?(data_consistency)
       raise ArgumentError, 'Data consistency is already set' if class_attributes[:data_consistency]
 
-      class_attributes[:data_consistency_feature_flag] = feature_flag if feature_flag
-      class_attributes[:data_consistency] = data_consistency
+      set_class_attribute(:data_consistency_feature_flag, feature_flag) if feature_flag
+      set_class_attribute(:data_consistency, data_consistency)
 
       validate_worker_attributes!
     end
@@ -105,7 +105,7 @@ def get_data_consistency_feature_flag_enabled?
     # doc/development/sidekiq_style_guide.md#jobs-with-external-dependencies for
     # details
     def worker_has_external_dependencies!
-      class_attributes[:external_dependencies] = true
+      set_class_attribute(:external_dependencies, true)
     end
 
     # Returns a truthy value if the worker has external dependencies.
@@ -118,7 +118,7 @@ def worker_has_external_dependencies?
     def worker_resource_boundary(boundary)
       raise "Invalid boundary" unless VALID_RESOURCE_BOUNDARIES.include? boundary
 
-      class_attributes[:resource_boundary] = boundary
+      set_class_attribute(:resource_boundary, boundary)
     end
 
     def get_worker_resource_boundary
@@ -126,7 +126,7 @@ def get_worker_resource_boundary
     end
 
     def idempotent!
-      class_attributes[:idempotent] = true
+      set_class_attribute(:idempotent, true)
 
       validate_worker_attributes!
     end
@@ -136,7 +136,7 @@ def idempotent?
     end
 
     def weight(value)
-      class_attributes[:weight] = value
+      set_class_attribute(:weight, value)
     end
 
     def get_weight
@@ -146,7 +146,7 @@ def get_weight
     end
 
     def tags(*values)
-      class_attributes[:tags] = values
+      set_class_attribute(:tags, values)
     end
 
     def get_tags
@@ -154,8 +154,8 @@ def get_tags
     end
 
     def deduplicate(strategy, options = {})
-      class_attributes[:deduplication_strategy] = strategy
-      class_attributes[:deduplication_options] = options
+      set_class_attribute(:deduplication_strategy, strategy)
+      set_class_attribute(:deduplication_options, options)
     end
 
     def get_deduplicate_strategy
@@ -168,7 +168,7 @@ def get_deduplication_options
     end
 
     def big_payload!
-      class_attributes[:big_payload] = true
+      set_class_attribute(:big_payload, true)
     end
 
     def big_payload?
diff --git a/changelogs/unreleased/qmnguyen0711-1016-implement-sidekiq-queue-re-routing-in-the-application-s.yml b/changelogs/unreleased/qmnguyen0711-1016-implement-sidekiq-queue-re-routing-in-the-application-s.yml
new file mode 100644
index 0000000000000..1db2f491f742f
--- /dev/null
+++ b/changelogs/unreleased/qmnguyen0711-1016-implement-sidekiq-queue-re-routing-in-the-application-s.yml
@@ -0,0 +1,5 @@
+---
+title: Implement Sidekiq queue re-routing in the application
+merge_request: 59604
+author:
+type: added
diff --git a/config/gitlab.yml.example b/config/gitlab.yml.example
index da1a15302da39..456a0b926cbca 100644
--- a/config/gitlab.yml.example
+++ b/config/gitlab.yml.example
@@ -438,6 +438,12 @@ production: &base
   ## Sidekiq
   sidekiq:
     log_format: json # (default is the original format)
+    # An array of tuples indicating the rules for re-routing a worker to a
+    # desirable queue before scheduling. For example:
+    # routing_rules:
+    #   - ["resource_boundary=cpu", "cpu_boundary"]
+    #   - ["feature_category=pages", null]
+    #   - ["*", "default"]
 
   ## Auxiliary jobs
   # Periodically executed jobs, to self-heal GitLab, do external synchronizations, etc.
diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb
index 4beaa2da810d0..613e855ab3d38 100644
--- a/config/initializers/1_settings.rb
+++ b/config/initializers/1_settings.rb
@@ -698,6 +698,7 @@
 #
 Settings['sidekiq'] ||= Settingslogic.new({})
 Settings['sidekiq']['log_format'] ||= 'default'
+Settings['sidekiq']['routing_rules'] ||= []
 
 #
 # GitLab Shell
diff --git a/lib/gitlab/class_attributes.rb b/lib/gitlab/class_attributes.rb
index 6560c97b2e6f0..6eea7590cbd5c 100644
--- a/lib/gitlab/class_attributes.rb
+++ b/lib/gitlab/class_attributes.rb
@@ -14,6 +14,18 @@ def get_class_attribute(name)
         class_attributes[name] || superclass_attributes(name)
       end
 
+      def set_class_attribute(name, value)
+        class_attributes[name] = value
+
+        after_hooks.each(&:call)
+
+        value
+      end
+
+      def after_set_class_attribute(&block)
+        after_hooks << block
+      end
+
       private
 
       def class_attributes
@@ -25,6 +37,10 @@ def superclass_attributes(name)
 
         superclass.get_class_attribute(name)
       end
+
+      def after_hooks
+        @after_hooks ||= []
+      end
     end
   end
 end
diff --git a/lib/gitlab/sidekiq_config/worker_router.rb b/lib/gitlab/sidekiq_config/worker_router.rb
new file mode 100644
index 0000000000000..946296a24d307
--- /dev/null
+++ b/lib/gitlab/sidekiq_config/worker_router.rb
@@ -0,0 +1,107 @@
+# frozen_string_literal: true
+
+module Gitlab
+  module SidekiqConfig
+    class WorkerRouter
+      InvalidRoutingRuleError = Class.new(StandardError)
+      RuleEvaluator = Struct.new(:matcher, :queue_name)
+
+      def self.queue_name_from_worker_name(worker_klass)
+        base_queue_name =
+          worker_klass.name
+            .delete_prefix('Gitlab::')
+            .delete_suffix('Worker')
+            .underscore
+            .tr('/', '_')
+        [worker_klass.queue_namespace, base_queue_name].compact.join(':')
+      end
+
+      def self.global
+        @global_worker_router ||= new(::Gitlab.config.sidekiq.routing_rules)
+      rescue InvalidRoutingRuleError, ::Gitlab::SidekiqConfig::WorkerMatcher::UnknownPredicate => e
+        ::Gitlab::ErrorTracking.track_and_raise_for_dev_exception(e)
+
+        @global_worker_router = new([])
+      end
+
+      # call-seq:
+      #   router = WorkerRouter.new([
+      #     ["resource_boundary=cpu", 'cpu_boundary'],
+      #     ["feature_category=pages", nil],
+      #     ["feature_category=source_code_management", ''],
+      #     ["*", "default"]
+      #   ])
+      #   router.route(ACpuBoundaryWorker) # Return "cpu_boundary"
+      #   router.route(JustAPagesWorker)   # Return "just_a_pages_worker"
+      #   router.route(PostReceive)        # Return "post_receive"
+      #   router.route(RandomWorker)       # Return "default"
+      #
+      # This class is responsible for routing a Sidekiq worker to a certain
+      # queue defined in the input routing rules. The input routing rules, as
+      # described above, is an order-matter array of tuples [query, queue_name].
+      #
+      # - The query syntax is the same as the "queue selector" detailedly
+      # denoted in doc/administration/operations/extra_sidekiq_processes.md.
+      #
+      # - The queue_name must be a valid Sidekiq queue name. If the queue name
+      # is nil, or an empty string, the worker is routed to the queue generated
+      # by the name of the worker instead.
+      #
+      # Rules are evaluated from first to last, and as soon as we find a match
+      # for a given worker we stop processing for that worker (first match
+      # wins). If the worker doesn't match any rule, it falls back the queue
+      # name generated from the worker name
+      #
+      # For further information, please visit:
+      #   https://gitlab.com/gitlab-com/gl-infra/scalability/-/issues/1016
+      #
+      def initialize(routing_rules)
+        @rule_evaluators = parse_routing_rules(routing_rules)
+      end
+
+      def route(worker_klass)
+        # A medium representation to ensure the backward-compatibility of
+        # WorkerMatcher
+        worker_metadata = generate_worker_metadata(worker_klass)
+        @rule_evaluators.each do |evaluator|
+          if evaluator.matcher.match?(worker_metadata)
+            return evaluator.queue_name.presence || queue_name_from_worker_name(worker_klass)
+          end
+        end
+
+        queue_name_from_worker_name(worker_klass)
+      end
+
+      private
+
+      def parse_routing_rules(routing_rules)
+        raise InvalidRoutingRuleError, 'The set of routing rule must be an array' unless routing_rules.is_a?(Array)
+
+        routing_rules.map do |rule_tuple|
+          raise InvalidRoutingRuleError, "Routing rule `#{rule_tuple.inspect}` is invalid" unless valid_routing_rule?(rule_tuple)
+
+          selector, destination_queue = rule_tuple
+          RuleEvaluator.new(
+            ::Gitlab::SidekiqConfig::WorkerMatcher.new(selector),
+            destination_queue
+          )
+        end
+      end
+
+      def valid_routing_rule?(rule_tuple)
+        rule_tuple.is_a?(Array) && rule_tuple.length == 2
+      end
+
+      def generate_worker_metadata(worker_klass)
+        # The ee indicator here is insignificant and irrelevant to the matcher.
+        # Plus, it's not easy to determine whether a worker is **only**
+        # available in EE.
+        ::Gitlab::SidekiqConfig::Worker.new(worker_klass, ee: false).to_yaml
+      end
+
+      def queue_name_from_worker_name(worker_klass)
+        self.class.queue_name_from_worker_name(worker_klass)
+      end
+    end
+  end
+end
diff --git a/spec/lib/gitlab/class_attributes_spec.rb b/spec/lib/gitlab/class_attributes_spec.rb
index f8766f2049505..ac2a18a18605b 100644
--- a/spec/lib/gitlab/class_attributes_spec.rb
+++ b/spec/lib/gitlab/class_attributes_spec.rb
@@ -6,36 +6,62 @@
     Class.new do
       include Gitlab::ClassAttributes
 
-      def self.get_attribute(name)
-        get_class_attribute(name)
+      class << self
+        attr_reader :counter_1, :counter_2
+
+        # get_class_attribute and set_class_attribute are protected,
+        # hence those methods are for testing purpose
+        def get_attribute(name)
+          get_class_attribute(name)
+        end
+
+        def set_attribute(name, value)
+          set_class_attribute(name, value)
+        end
+      end
+
+      after_set_class_attribute do
+        @counter_1 ||= 0
+        @counter_1 += 1
       end
 
-      def self.set_attribute(name, value)
-        class_attributes[name] = value
+      after_set_class_attribute do
+        @counter_2 ||= 0
+        @counter_2 += 2
       end
     end
   end
 
   let(:subclass) { Class.new(klass) }
 
-  describe ".get_class_attribute" do
-    it "returns values set on the class" do
-      klass.set_attribute(:foo, :bar)
+  it "returns values set on the class" do
+    klass.set_attribute(:foo, :bar)
 
-      expect(klass.get_attribute(:foo)).to eq(:bar)
-    end
+    expect(klass.get_attribute(:foo)).to eq(:bar)
+  end
 
-    it "returns values set on a superclass" do
-      klass.set_attribute(:foo, :bar)
+  it "returns values set on a superclass" do
+    klass.set_attribute(:foo, :bar)
 
-      expect(subclass.get_attribute(:foo)).to eq(:bar)
-    end
+    expect(subclass.get_attribute(:foo)).to eq(:bar)
+  end
 
-    it "returns values from the subclass over attributes from a superclass" do
-      klass.set_attribute(:foo, :baz)
-      subclass.set_attribute(:foo, :bar)
+  it "returns values from the subclass over attributes from a superclass" do
+    klass.set_attribute(:foo, :baz)
+    subclass.set_attribute(:foo, :bar)
 
-      expect(subclass.get_attribute(:foo)).to eq(:bar)
-    end
+    expect(klass.get_attribute(:foo)).to eq(:baz)
+    expect(subclass.get_attribute(:foo)).to eq(:bar)
+  end
+
+  it "triggers after hooks after set class values" do
+    expect(klass.counter_1).to be(nil)
+    expect(klass.counter_2).to be(nil)
+
+    klass.set_attribute(:foo, :bar)
+    klass.set_attribute(:foo, :bar)
+
+    expect(klass.counter_1).to eq(2)
+    expect(klass.counter_2).to eq(4)
   end
 end
diff --git a/spec/lib/gitlab/sidekiq_config/worker_router_spec.rb b/spec/lib/gitlab/sidekiq_config/worker_router_spec.rb
new file mode 100644
index 0000000000000..687e35813b1cf
--- /dev/null
+++ b/spec/lib/gitlab/sidekiq_config/worker_router_spec.rb
@@ -0,0 +1,212 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require 'rspec-parameterized'
+
+RSpec.describe Gitlab::SidekiqConfig::WorkerRouter do
+  describe '.queue_name_from_worker_name' do
+    using RSpec::Parameterized::TableSyntax
+
+    def create_worker(name, namespace = nil)
+      Class.new.tap do |worker|
+        worker.define_singleton_method(:name) { name }
+        worker.define_singleton_method(:queue_namespace) { namespace }
+      end
+    end
+
+    where(:worker, :expected_name) do
+      create_worker('PagesWorker') | 'pages'
+      create_worker('PipelineNotificationWorker') | 'pipeline_notification'
+      create_worker('PostReceive') | 'post_receive'
+      create_worker('PostReceive', :git) | 'git:post_receive'
+      create_worker('PipelineHooksWorker', :pipeline_hooks) | 'pipeline_hooks:pipeline_hooks'
+      create_worker('Gitlab::JiraImport::AdvanceStageWorker') | 'jira_import_advance_stage'
+      create_worker('Gitlab::PhabricatorImport::ImportTasksWorker', :importer) | 'importer:phabricator_import_import_tasks'
+    end
+
+    with_them do
+      it 'generates a valid queue name from worker name' do
+        expect(described_class.queue_name_from_worker_name(worker)).to eql(expected_name)
+      end
+    end
+  end
+
+  shared_context 'router examples setup' do
+    using RSpec::Parameterized::TableSyntax
+
+    let(:worker) do
+      Class.new do
+        def self.name
+          'Gitlab::Foo::BarWorker'
+        end
+
+        include ApplicationWorker
+
+        feature_category :feature_a
+        urgency :low
+        worker_resource_boundary :cpu
+        tags :expensive
+      end
+    end
+
+    where(:routing_rules, :expected_queue) do
+      # Default, no configuration
+      [] | 'foo_bar'
+      # Does not match, fallback to the named queue
+      [
+        ['feature_category=feature_b|urgency=high', 'queue_a'],
+        ['resource_boundary=memory', 'queue_b'],
+        ['tags=cheap', 'queue_c']
+      ] | 'foo_bar'
+      # Match a nil queue, fallback to named queue
+      [
+        ['feature_category=feature_b|urgency=high', 'queue_a'],
+        ['resource_boundary=cpu', nil],
+        ['tags=cheap', 'queue_c']
+      ] | 'foo_bar'
+      # Match an empty string, fallback to named queue
+      [
+        ['feature_category=feature_b|urgency=high', 'queue_a'],
+        ['resource_boundary=cpu', ''],
+        ['tags=cheap', 'queue_c']
+      ] | 'foo_bar'
+      # Match the first rule
+      [
+        ['feature_category=feature_a|urgency=high', 'queue_a'],
+        ['resource_boundary=cpu', 'queue_b'],
+        ['tags=cheap', 'queue_c']
+      ] | 'queue_a'
+      # Match the first rule 2
+      [
+        ['feature_category=feature_b|urgency=low', 'queue_a'],
+        ['resource_boundary=cpu', 'queue_b'],
+        ['tags=cheap', 'queue_c']
+      ] | 'queue_a'
+      # Match the third rule
+      [
+        ['feature_category=feature_b|urgency=high', 'queue_a'],
+        ['resource_boundary=memory', 'queue_b'],
+        ['tags=expensive', 'queue_c']
+      ] | 'queue_c'
+      # Match all, first match wins
+      [
+        ['feature_category=feature_a|urgency=low', 'queue_a'],
+        ['resource_boundary=cpu', 'queue_b'],
+        ['tags=expensive', 'queue_c']
+      ] | 'queue_a'
+      # Match the same rule multiple times, the first match wins
+      [
+        ['feature_category=feature_a', 'queue_a'],
+        ['feature_category=feature_a', 'queue_b'],
+        ['feature_category=feature_a', 'queue_c']
+      ] | 'queue_a'
+      # Match wildcard
+      [
+        ['feature_category=feature_b|urgency=high', 'queue_a'],
+        ['resource_boundary=memory', 'queue_b'],
+        ['tags=cheap', 'queue_c'],
+        ['*', 'default']
+      ] | 'default'
+      # Match wildcard at the top of the chain. It makes the following rules useless
+      [
+        ['*', 'queue_foo'],
+        ['feature_category=feature_a|urgency=low', 'queue_a'],
+        ['resource_boundary=cpu', 'queue_b'],
+        ['tags=expensive', 'queue_c']
+      ] | 'queue_foo'
+    end
+  end
+
+  describe '.global' do
+    before do
+      described_class.remove_instance_variable(:@global_worker_router) if described_class.instance_variable_defined?(:@global_worker_router)
+    end
+
+    after do
+      described_class.remove_instance_variable(:@global_worker_router)
+    end
+
+    context 'valid routing rules' do
+      include_context 'router examples setup'
+
+      with_them do
+        before do
+          stub_config(sidekiq: { routing_rules: routing_rules })
+        end
+
+        it 'routes the worker to the correct queue' do
+          expect(described_class.global.route(worker)).to eql(expected_queue)
+        end
+      end
+    end
+
+    context 'invalid routing rules' do
+      let(:worker) do
+        Class.new do
+          def self.name
+            'Gitlab::Foo::BarWorker'
+          end
+
+          include ApplicationWorker
+        end
+      end
+
+      before do
+        stub_config(sidekiq: { routing_rules: routing_rules })
+      end
+
+      context 'invalid routing rules format' do
+        let(:routing_rules) { ['feature_category=a'] }
+
+        it 'captures the error and falls back to an empty route' do
+          expect(Gitlab::ErrorTracking).to receive(:track_and_raise_for_dev_exception).with(be_a(described_class::InvalidRoutingRuleError))
+
+          expect(described_class.global.route(worker)).to eql('foo_bar')
+        end
+      end
+
+      context 'invalid predicate' do
+        let(:routing_rules) { [['invalid_term=a', 'queue_a']] }
+
+        it 'captures the error and falls back to an empty route' do
+          expect(Gitlab::ErrorTracking).to receive(:track_and_raise_for_dev_exception).with(
+            be_a(Gitlab::SidekiqConfig::WorkerMatcher::UnknownPredicate)
+          )
+
+          expect(described_class.global.route(worker)).to eql('foo_bar')
+        end
+      end
+    end
+  end
+
+  describe '#route' do
+    context 'valid routing rules' do
+      include_context 'router examples setup'
+
+      with_them do
+        it 'routes the worker to the correct queue' do
+          router = described_class.new(routing_rules)
+
+          expect(router.route(worker)).to eql(expected_queue)
+        end
+      end
+    end
+
+    context 'invalid routing rules' do
+      it 'raises an exception' do
+        expect { described_class.new(nil) }.to raise_error(described_class::InvalidRoutingRuleError)
+        expect { described_class.new(['feature_category=a']) }.to raise_error(described_class::InvalidRoutingRuleError)
+        expect { described_class.new([['feature_category=a', 'queue_a', 'queue_b']]) }.to raise_error(described_class::InvalidRoutingRuleError)
+        expect do
+          described_class.new(
+            [
+              ['feature_category=a', 'queue_b'],
+              ['feature_category=b']
+            ]
+          )
+        end.to raise_error(described_class::InvalidRoutingRuleError)
+        expect { described_class.new([['invalid_term=a', 'queue_a']]) }.to raise_error(Gitlab::SidekiqConfig::WorkerMatcher::UnknownPredicate)
+      end
+    end
+  end
+end
diff --git a/spec/workers/concerns/application_worker_spec.rb b/spec/workers/concerns/application_worker_spec.rb
index 07e11f014c391..5c1a1d3ae8f56 100644
--- a/spec/workers/concerns/application_worker_spec.rb
+++ b/spec/workers/concerns/application_worker_spec.rb
@@ -3,7 +3,14 @@
 require 'spec_helper'
 
 RSpec.describe ApplicationWorker do
-  let_it_be(:worker) do
+  # We depend on the lazy-load characteristic of rspec. If the worker is loaded
+  # before setting up, it's likely to go wrong. Consider this catcha:
+  # before do
+  #   allow(router).to receive(:route).with(worker).and_return('queue_1')
+  # end
+  # As worker is triggered, it includes ApplicationWorker, and the router is
+  # called before it is stubbed. That makes the stubbing useless.
+  let(:worker) do
     Class.new do
       def self.name
         'Gitlab::Foo::Bar::DummyWorker'
@@ -14,10 +21,77 @@ def self.name
   end
 
   let(:instance) { worker.new }
+  let(:router) { double(:router) }
 
-  describe 'Sidekiq options' do
-    it 'sets the queue name based on the class name' do
+  before do
+    allow(::Gitlab::SidekiqConfig::WorkerRouter).to receive(:global).and_return(router)
+    allow(router).to receive(:route).and_return('foo_bar_dummy')
+  end
+
+  describe 'Sidekiq attributes' do
+    it 'sets the queue name based on the output of the router' do
       expect(worker.sidekiq_options['queue']).to eq('foo_bar_dummy')
+      expect(router).to have_received(:route).with(worker).at_least(:once)
+    end
+
+    context 'when a worker attribute is updated' do
+      before do
+        counter = 0
+        allow(router).to receive(:route) do
+          counter += 1
+          "queue_#{counter}"
+        end
+      end
+
+      it 'updates the queue name afterward' do
+        expect(worker.sidekiq_options['queue']).to eq('queue_1')
+
+        worker.feature_category :pages
+        expect(worker.sidekiq_options['queue']).to eq('queue_2')
+
+        worker.feature_category_not_owned!
+        expect(worker.sidekiq_options['queue']).to eq('queue_3')
+
+        worker.urgency :high
+        expect(worker.sidekiq_options['queue']).to eq('queue_4')
+
+        worker.worker_has_external_dependencies!
+        expect(worker.sidekiq_options['queue']).to eq('queue_5')
+
+        worker.worker_resource_boundary :cpu
+        expect(worker.sidekiq_options['queue']).to eq('queue_6')
+
+        worker.idempotent!
+        expect(worker.sidekiq_options['queue']).to eq('queue_7')
+
+        worker.weight 3
+        expect(worker.sidekiq_options['queue']).to eq('queue_8')
+
+        worker.tags :hello
+        expect(worker.sidekiq_options['queue']).to eq('queue_9')
+
+        worker.big_payload!
+        expect(worker.sidekiq_options['queue']).to eq('queue_10')
+
+        expect(router).to have_received(:route).with(worker).at_least(10).times
+      end
+    end
+
+    context 'when the worker is inherited' do
+      let(:sub_worker) { Class.new(worker) }
+
+      before do
+        allow(router).to receive(:route).and_return('queue_1')
+        worker # Force loading worker 1 to update its queue
+
+        allow(router).to receive(:route).and_return('queue_2')
+      end
+
+      it 'sets the queue name for the inherited worker' do
+        expect(sub_worker.sidekiq_options['queue']).to eq('queue_2')
+
+        expect(router).to have_received(:route).with(sub_worker).at_least(:once)
+      end
     end
   end
 
@@ -74,11 +148,24 @@ def self.name
   end
 
   describe '.queue_namespace' do
-    it 'sets the queue name based on the class name' do
+    before do
+      allow(router).to receive(:route).and_return('foo_bar_dummy', 'some_namespace:foo_bar_dummy')
+    end
+
+    it 'updates the queue name from the router again' do
+      expect(worker.queue).to eq('foo_bar_dummy')
+
       worker.queue_namespace :some_namespace
 
       expect(worker.queue).to eq('some_namespace:foo_bar_dummy')
     end
+
+    it 'updates the queue_namespace options of the worker' do
+      worker.queue_namespace :some_namespace
+
+      expect(worker.queue_namespace).to eql('some_namespace')
+      expect(worker.sidekiq_options['queue_namespace']).to be(:some_namespace)
+    end
   end
 
   describe '.queue' do
-- 
GitLab