From a7f569b2e9a87c17d373b4914db00e8b859518e7 Mon Sep 17 00:00:00 2001
From: Stan Hu <stanhu@gmail.com>
Date: Wed, 14 Feb 2024 16:52:28 +0000
Subject: [PATCH] Revert Redis v5 gem upgrade

This reverts merge request !144300
---
 .rubocop_todo/layout/line_length.yml          |   1 +
 .../lint/ambiguous_operator_precedence.yml    |   1 +
 .rubocop_todo/rspec/feature_category.yml      |   1 +
 .rubocop_todo/rspec/named_subject.yml         |   1 +
 .../style/inline_disable_annotation.yml       |   5 +
 Gemfile                                       |   5 +-
 Gemfile.checksum                              |   4 +-
 Gemfile.lock                                  |  11 +-
 app/models/active_session.rb                  |   2 +-
 .../concerns/limited_capacity/job_tracker.rb  |   4 +-
 config/initializers/7_redis.rb                |   6 +
 config/initializers/action_cable.rb           |   2 +-
 config/initializers/peek.rb                   |   2 +-
 config/initializers/session_store.rb          |   4 +-
 config/initializers/sidekiq.rb                |   8 +-
 doc/administration/redis/troubleshooting.md   |   2 +-
 doc/development/redis.md                      |   4 +-
 doc/development/redis/new_redis_instance.md   |   4 +-
 .../elastic/indexing_control_service.rb       |   2 +-
 .../backfill_project_pipeline_status_ttl.rb   |   4 +-
 lib/gitlab/cache/import/caching.rb            |   4 +-
 lib/gitlab/diff/highlight_cache.rb            |   4 +-
 .../discussions_diff/highlight_cache.rb       |   6 +-
 lib/gitlab/etag_caching/store.rb              |   2 +-
 lib/gitlab/exclusive_lease.rb                 |   3 +-
 .../redis_cluster_validator.rb                |   4 +-
 .../instrumentation/redis_interceptor.rb      |  55 ++++
 lib/gitlab/issues/rebalancing/state.rb        |   2 +-
 lib/gitlab/markdown_cache/redis/store.rb      |   2 +-
 lib/gitlab/patch/command_loader.rb            |  19 ++
 lib/gitlab/patch/node_loader.rb               |  52 ++++
 lib/gitlab/patch/redis_cache_store.rb         |   4 +-
 lib/gitlab/patch/redis_cluster.rb             |  21 ++
 lib/gitlab/patch/redis_store_factory.rb       |  16 --
 lib/gitlab/patch/slot_loader.rb               |  19 ++
 lib/gitlab/redis/cluster_store.rb             |  81 ------
 lib/gitlab/redis/cluster_util.rb              |   6 +-
 lib/gitlab/redis/command_builder.rb           |  53 ----
 lib/gitlab/redis/cross_slot.rb                | 141 ++++++++++
 lib/gitlab/redis/multi_store.rb               |  12 +-
 lib/gitlab/redis/wrapper.rb                   |  37 +--
 lib/gitlab/repository_hash_cache.rb           |   2 -
 lib/gitlab/set_cache.rb                       |   2 +-
 .../duplicate_jobs/duplicate_job.rb           |   2 +-
 .../product_analytics_tracking_spec.rb        |   3 +-
 .../redis_new_format_host_standalone.yml      |   8 -
 ...le_subscription_adapter_identifier_spec.rb |   2 +-
 spec/initializers/session_store_spec.rb       |   2 +-
 .../migration_support/exclusive_lock_spec.rb  |   4 +-
 ...ckfill_project_pipeline_status_ttl_spec.rb |   2 +-
 .../runner_backoff/communicator_spec.rb       |   2 +-
 .../discussions_diff/highlight_cache_spec.rb  |   2 +-
 .../external_authorization/cache_spec.rb      |   4 +-
 .../redis_client_middleware_spec.rb           | 111 +++-----
 .../instrumentation/redis_interceptor_spec.rb | 261 ++++++++++++++++++
 spec/lib/gitlab/patch/node_loader_spec.rb     |  80 ++++++
 .../gitlab/patch/redis_cache_store_spec.rb    |  13 +-
 spec/lib/gitlab/patch/redis_client_spec.rb    |   2 +-
 .../gitlab/patch/redis_store_factory_spec.rb  |  29 --
 spec/lib/gitlab/rack_attack/store_spec.rb     |   2 +-
 spec/lib/gitlab/redis/cluster_store_spec.rb   | 116 --------
 spec/lib/gitlab/redis/cluster_util_spec.rb    |  27 +-
 spec/lib/gitlab/redis/command_builder_spec.rb |  57 ----
 spec/lib/gitlab/redis/cross_slot_spec.rb      | 134 +++++++++
 spec/lib/gitlab/redis/multi_store_spec.rb     |  31 ++-
 spec/lib/gitlab/redis/sessions_spec.rb        |   4 +-
 spec/spec_helper.rb                           |  10 +-
 spec/support/helpers/dns_helpers.rb           |   4 +-
 .../matchers/exceed_redis_call_limit.rb       |   2 +-
 spec/support/rspec_order_todo.yml             |   1 +
 .../object_import_shared_examples.rb          |  10 +-
 .../object_import_shared_examples.rb          |   4 -
 .../redis/redis_shared_examples.rb            |  79 ++++--
 .../helpers/redis_commands/recorder_spec.rb   |  40 +--
 .../import_gist_worker_spec.rb                |  15 +-
 .../jira_import/import_issue_worker_spec.rb   |   4 +-
 spec/workers/redis_migration_worker_spec.rb   |   2 +-
 77 files changed, 1071 insertions(+), 618 deletions(-)
 create mode 100644 lib/gitlab/instrumentation/redis_interceptor.rb
 create mode 100644 lib/gitlab/patch/command_loader.rb
 create mode 100644 lib/gitlab/patch/node_loader.rb
 create mode 100644 lib/gitlab/patch/redis_cluster.rb
 delete mode 100644 lib/gitlab/patch/redis_store_factory.rb
 create mode 100644 lib/gitlab/patch/slot_loader.rb
 delete mode 100644 lib/gitlab/redis/cluster_store.rb
 delete mode 100644 lib/gitlab/redis/command_builder.rb
 create mode 100644 lib/gitlab/redis/cross_slot.rb
 delete mode 100644 spec/fixtures/config/redis_new_format_host_standalone.yml
 create mode 100644 spec/lib/gitlab/instrumentation/redis_interceptor_spec.rb
 create mode 100644 spec/lib/gitlab/patch/node_loader_spec.rb
 delete mode 100644 spec/lib/gitlab/patch/redis_store_factory_spec.rb
 delete mode 100644 spec/lib/gitlab/redis/cluster_store_spec.rb
 delete mode 100644 spec/lib/gitlab/redis/command_builder_spec.rb
 create mode 100644 spec/lib/gitlab/redis/cross_slot_spec.rb

diff --git a/.rubocop_todo/layout/line_length.yml b/.rubocop_todo/layout/line_length.yml
index 754d31a99b2dc..2095d2c1ca3e1 100644
--- a/.rubocop_todo/layout/line_length.yml
+++ b/.rubocop_todo/layout/line_length.yml
@@ -3703,6 +3703,7 @@ Layout/LineLength:
     - 'spec/lib/gitlab/import_export/uploads_manager_spec.rb'
     - 'spec/lib/gitlab/import_export/version_checker_spec.rb'
     - 'spec/lib/gitlab/import_sources_spec.rb'
+    - 'spec/lib/gitlab/instrumentation/redis_interceptor_spec.rb'
     - 'spec/lib/gitlab/issuable_metadata_spec.rb'
     - 'spec/lib/gitlab/issues/rebalancing/state_spec.rb'
     - 'spec/lib/gitlab/jira/dvcs_spec.rb'
diff --git a/.rubocop_todo/lint/ambiguous_operator_precedence.yml b/.rubocop_todo/lint/ambiguous_operator_precedence.yml
index a7cac8214c08c..5b352842a870d 100644
--- a/.rubocop_todo/lint/ambiguous_operator_precedence.yml
+++ b/.rubocop_todo/lint/ambiguous_operator_precedence.yml
@@ -102,6 +102,7 @@ Lint/AmbiguousOperatorPrecedence:
     - 'spec/lib/gitlab/database/batch_count_spec.rb'
     - 'spec/lib/gitlab/database/consistency_checker_spec.rb'
     - 'spec/lib/gitlab/graphql/tracers/metrics_tracer_spec.rb'
+    - 'spec/lib/gitlab/instrumentation/redis_interceptor_spec.rb'
     - 'spec/lib/gitlab/issues/rebalancing/state_spec.rb'
     - 'spec/lib/gitlab/kroki_spec.rb'
     - 'spec/lib/gitlab/memory/instrumentation_spec.rb'
diff --git a/.rubocop_todo/rspec/feature_category.yml b/.rubocop_todo/rspec/feature_category.yml
index c13df3a295bd0..bfb96e1a6a1aa 100644
--- a/.rubocop_todo/rspec/feature_category.yml
+++ b/.rubocop_todo/rspec/feature_category.yml
@@ -3586,6 +3586,7 @@ RSpec/FeatureCategory:
     - 'spec/lib/gitlab/instrumentation/rate_limiting_gates_spec.rb'
     - 'spec/lib/gitlab/instrumentation/redis_base_spec.rb'
     - 'spec/lib/gitlab/instrumentation/redis_cluster_validator_spec.rb'
+    - 'spec/lib/gitlab/instrumentation/redis_interceptor_spec.rb'
     - 'spec/lib/gitlab/instrumentation/redis_spec.rb'
     - 'spec/lib/gitlab/internal_post_receive/response_spec.rb'
     - 'spec/lib/gitlab/issuable/clone/attributes_rewriter_spec.rb'
diff --git a/.rubocop_todo/rspec/named_subject.yml b/.rubocop_todo/rspec/named_subject.yml
index 5520a5210f983..6d447453985c6 100644
--- a/.rubocop_todo/rspec/named_subject.yml
+++ b/.rubocop_todo/rspec/named_subject.yml
@@ -2331,6 +2331,7 @@ RSpec/NamedSubject:
     - 'spec/lib/gitlab/rack_attack_spec.rb'
     - 'spec/lib/gitlab/reactive_cache_set_cache_spec.rb'
     - 'spec/lib/gitlab/redis/boolean_spec.rb'
+    - 'spec/lib/gitlab/redis/cross_slot_spec.rb'
     - 'spec/lib/gitlab/redis/db_load_balancing_spec.rb'
     - 'spec/lib/gitlab/redis/multi_store_spec.rb'
     - 'spec/lib/gitlab/redis/queues_spec.rb'
diff --git a/.rubocop_todo/style/inline_disable_annotation.yml b/.rubocop_todo/style/inline_disable_annotation.yml
index 31f59f51bf7c6..4bf891cd5c33c 100644
--- a/.rubocop_todo/style/inline_disable_annotation.yml
+++ b/.rubocop_todo/style/inline_disable_annotation.yml
@@ -2517,6 +2517,7 @@ Style/InlineDisableAnnotation:
     - 'lib/gitlab/import_export/project/relation_factory.rb'
     - 'lib/gitlab/import_sources.rb'
     - 'lib/gitlab/instrumentation/redis_cluster_validator.rb'
+    - 'lib/gitlab/instrumentation/redis_interceptor.rb'
     - 'lib/gitlab/internal_events.rb'
     - 'lib/gitlab/issuable/clone/copy_resource_events_service.rb'
     - 'lib/gitlab/issues/rebalancing/state.rb'
@@ -2555,6 +2556,7 @@ Style/InlineDisableAnnotation:
     - 'lib/gitlab/pagination/offset_pagination.rb'
     - 'lib/gitlab/pagination_delegate.rb'
     - 'lib/gitlab/patch/action_cable_subscription_adapter_identifier.rb'
+    - 'lib/gitlab/patch/node_loader.rb'
     - 'lib/gitlab/patch/prependable.rb'
     - 'lib/gitlab/patch/redis_cache_store.rb'
     - 'lib/gitlab/patch/sidekiq_cron_poller.rb'
@@ -2571,6 +2573,7 @@ Style/InlineDisableAnnotation:
     - 'lib/gitlab/rack_attack.rb'
     - 'lib/gitlab/rack_attack/request.rb'
     - 'lib/gitlab/rack_attack/store.rb'
+    - 'lib/gitlab/redis/cross_slot.rb'
     - 'lib/gitlab/redis/hll.rb'
     - 'lib/gitlab/redis/multi_store.rb'
     - 'lib/gitlab/reference_extractor.rb'
@@ -2939,7 +2942,9 @@ Style/InlineDisableAnnotation:
     - 'spec/lib/gitlab/pagination/keyset/order_spec.rb'
     - 'spec/lib/gitlab/pagination/keyset/simple_order_builder_spec.rb'
     - 'spec/lib/gitlab/patch/database_config_spec.rb'
+    - 'spec/lib/gitlab/patch/node_loader_spec.rb'
     - 'spec/lib/gitlab/quick_actions/dsl_spec.rb'
+    - 'spec/lib/gitlab/redis/cross_slot_spec.rb'
     - 'spec/lib/gitlab/redis/multi_store_spec.rb'
     - 'spec/lib/gitlab/search/abuse_detection_spec.rb'
     - 'spec/lib/gitlab/shard_health_cache_spec.rb'
diff --git a/Gemfile b/Gemfile
index 527424e45f3f3..f2acfd06e73a7 100644
--- a/Gemfile
+++ b/Gemfile
@@ -288,9 +288,8 @@ gem 'js_regex', '~> 3.8' # rubocop:todo Gemfile/MissingFeatureCategory
 gem 'device_detector' # rubocop:todo Gemfile/MissingFeatureCategory
 
 # Redis
-gem 'redis-namespace', '~> 1.10.0', feature_category: :redis
-gem 'redis', '~> 5.0.0', feature_category: :redis
-gem 'redis-clustering', '~> 5.0.0', feature_category: :redis
+gem 'redis', '~> 4.8.0' # rubocop:todo Gemfile/MissingFeatureCategory
+gem 'redis-namespace', '~> 1.10.0' # rubocop:todo Gemfile/MissingFeatureCategory
 gem 'connection_pool', '~> 2.4' # rubocop:todo Gemfile/MissingFeatureCategory
 
 # Redis session store
diff --git a/Gemfile.checksum b/Gemfile.checksum
index f7daa2a8e9d3d..6c0918adaec2c 100644
--- a/Gemfile.checksum
+++ b/Gemfile.checksum
@@ -523,11 +523,9 @@
 {"name":"recaptcha","version":"5.12.3","platform":"ruby","checksum":"37d1894add9e70a54d0c6c7f0ecbeedffbfa7d075acfbd4c509818dfdebdb7ee"},
 {"name":"recursive-open-struct","version":"1.1.3","platform":"ruby","checksum":"a3538a72552fcebcd0ada657bdff313641a4a5fbc482c08cfb9a65acb1c9de5a"},
 {"name":"redcarpet","version":"3.6.0","platform":"ruby","checksum":"8ad1889c0355ff4c47174af14edd06d62f45a326da1da6e8a121d59bdcd2e9e9"},
-{"name":"redis","version":"5.0.8","platform":"ruby","checksum":"3b770ea597850b26d6a9718fa184241e53e6c8a7ae0486ee8bfaefd29f26f3d8"},
+{"name":"redis","version":"4.8.0","platform":"ruby","checksum":"2000cf5014669c9dc821704b6d322a35a9a33852a95208911d9175d63b448a44"},
 {"name":"redis-actionpack","version":"5.4.0","platform":"ruby","checksum":"f10cf649ab05914716d63334d7f709221ecc883b87cf348f90ecfe0c35ea3540"},
 {"name":"redis-client","version":"0.20.0","platform":"ruby","checksum":"239ac38fe4f0b62c8d2d8641989319b736b7dd40eebec868fd874a14686bdc8c"},
-{"name":"redis-cluster-client","version":"0.7.5","platform":"ruby","checksum":"12fd1c9eda17157a5cd2ce46afba13a024c28d24922092299a8daa9f46e4e78a"},
-{"name":"redis-clustering","version":"5.0.8","platform":"ruby","checksum":"8e2f3de3b1a700668eeac59125636e01be6ecd985e635a4d5649c47d71f6e166"},
 {"name":"redis-namespace","version":"1.10.0","platform":"ruby","checksum":"2c1c6ea7c6c5e343e75b9bee3aa4c265e364a5b9966507397467af2bb3758d94"},
 {"name":"redis-rack","version":"3.0.0","platform":"ruby","checksum":"abb50b82ae10ad4d11ca2e4901bfc2b98256cdafbbd95f80c86fc9e001478380"},
 {"name":"redis-store","version":"1.10.0","platform":"ruby","checksum":"f258894f9f7e82834308a3d86242294f0cff2c9db9ae66e5cb4c553a5ec8b09e"},
diff --git a/Gemfile.lock b/Gemfile.lock
index 5e8c7f544bb16..fcc61ec94abff 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -1385,19 +1385,13 @@ GEM
       json
     recursive-open-struct (1.1.3)
     redcarpet (3.6.0)
-    redis (5.0.8)
-      redis-client (>= 0.17.0)
+    redis (4.8.0)
     redis-actionpack (5.4.0)
       actionpack (>= 5, < 8)
       redis-rack (>= 2.1.0, < 4)
       redis-store (>= 1.1.0, < 2)
     redis-client (0.20.0)
       connection_pool
-    redis-cluster-client (0.7.5)
-      redis-client (~> 0.12)
-    redis-clustering (5.0.8)
-      redis (= 5.0.8)
-      redis-cluster-client (>= 0.7.0)
     redis-namespace (1.10.0)
       redis (>= 4)
     redis-rack (3.0.0)
@@ -2075,9 +2069,8 @@ DEPENDENCIES
   rbtrace (~> 0.4)
   re2 (= 2.7.0)
   recaptcha (~> 5.12)
-  redis (~> 5.0.0)
+  redis (~> 4.8.0)
   redis-actionpack (~> 5.4.0)
-  redis-clustering (~> 5.0.0)
   redis-namespace (~> 1.10.0)
   request_store (~> 1.5.1)
   responders (~> 3.0)
diff --git a/app/models/active_session.rb b/app/models/active_session.rb
index 756e0b7fbf9cd..2eb9c9bca7f24 100644
--- a/app/models/active_session.rb
+++ b/app/models/active_session.rb
@@ -84,7 +84,7 @@ def self.set(user, request)
       )
 
       Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do
-        redis.pipelined do |pipeline|
+        Gitlab::Redis::CrossSlot::Pipeline.new(redis).pipelined do |pipeline|
           pipeline.setex(
             key_name(user.id, session_private_id),
             expiry,
diff --git a/app/workers/concerns/limited_capacity/job_tracker.rb b/app/workers/concerns/limited_capacity/job_tracker.rb
index 2ac3c5e991bfb..b4d884f914df0 100644
--- a/app/workers/concerns/limited_capacity/job_tracker.rb
+++ b/app/workers/concerns/limited_capacity/job_tracker.rb
@@ -21,7 +21,7 @@ def initialize(namespace)
 
     def register(jid, max_jids)
       with_redis do |redis|
-        redis.eval(LUA_REGISTER_SCRIPT, keys: [counter_key], argv: [jid.to_s, max_jids.to_i])
+        redis.eval(LUA_REGISTER_SCRIPT, keys: [counter_key], argv: [jid, max_jids])
       end.present?
     end
 
@@ -59,7 +59,7 @@ def counter_key
     end
 
     def remove_job_keys(redis, keys)
-      redis.srem?(counter_key, keys) if keys.present?
+      redis.srem?(counter_key, keys)
     end
 
     def with_redis(&block)
diff --git a/config/initializers/7_redis.rb b/config/initializers/7_redis.rb
index c7385612602a0..fbd2cbaabad64 100644
--- a/config/initializers/7_redis.rb
+++ b/config/initializers/7_redis.rb
@@ -21,6 +21,12 @@
 # :nocov:
 # rubocop:enable Gitlab/NoCodeCoverageComment
 
+Redis::Client.prepend(Gitlab::Instrumentation::RedisInterceptor)
+Redis::Cluster::NodeLoader.prepend(Gitlab::Patch::NodeLoader)
+Redis::Cluster::SlotLoader.prepend(Gitlab::Patch::SlotLoader)
+Redis::Cluster::CommandLoader.prepend(Gitlab::Patch::CommandLoader)
+Redis::Cluster.prepend(Gitlab::Patch::RedisCluster)
+
 # this only instruments `RedisClient` used in `Sidekiq.redis`
 RedisClient.register(Gitlab::Instrumentation::RedisClientMiddleware)
 RedisClient.prepend(Gitlab::Patch::RedisClient)
diff --git a/config/initializers/action_cable.rb b/config/initializers/action_cable.rb
index 93fb80b38fa57..dc69a108f56e5 100644
--- a/config/initializers/action_cable.rb
+++ b/config/initializers/action_cable.rb
@@ -25,7 +25,7 @@
 # https://github.com/rails/rails/blob/bb5ac1623e8de08c1b7b62b1368758f0d3bb6379/actioncable/lib/action_cable/subscription_adapter/redis.rb#L18
 ActionCable::SubscriptionAdapter::Redis.redis_connector = lambda do |config|
   args = config.except(:adapter, :channel_prefix)
-    .merge(custom: { instrumentation_class: "ActionCable" })
+    .merge(instrumentation_class: ::Gitlab::Instrumentation::Redis::ActionCable)
 
   ::Redis.new(args)
 end
diff --git a/config/initializers/peek.rb b/config/initializers/peek.rb
index 72e7d3f10f98b..e1c59851fb1a9 100644
--- a/config/initializers/peek.rb
+++ b/config/initializers/peek.rb
@@ -6,7 +6,7 @@
 Peek.singleton_class.prepend ::Gitlab::PerformanceBar::WithTopLevelWarnings
 
 Rails.application.config.peek.adapter = :redis, {
-  client: Gitlab::Redis::Cache.redis,
+  client: ::Redis.new(Gitlab::Redis::Cache.params),
   expires_in: 5.minutes
 }
 
diff --git a/config/initializers/session_store.rb b/config/initializers/session_store.rb
index 70bd3d012d096..7f410d7bf7bbd 100644
--- a/config/initializers/session_store.rb
+++ b/config/initializers/session_store.rb
@@ -29,12 +29,12 @@
                "_gitlab_session"
              end
 
-::Redis::Store::Factory.prepend(Gitlab::Patch::RedisStoreFactory)
+store = Gitlab::Redis::Sessions.store(namespace: Gitlab::Redis::Sessions::SESSION_NAMESPACE)
 
 Rails.application.configure do
   config.session_store(
     :redis_store, # Using the cookie_store would enable session replay attacks.
-    redis_server: Gitlab::Redis::Sessions.params.merge(namespace: Gitlab::Redis::Sessions::SESSION_NAMESPACE),
+    redis_store: store,
     key: cookie_key,
     secure: Gitlab.config.gitlab.https,
     httponly: true,
diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb
index d7798a5ccd74c..926972a9d41e2 100644
--- a/config/initializers/sidekiq.rb
+++ b/config/initializers/sidekiq.rb
@@ -16,13 +16,7 @@ def load_cron_jobs!
 end
 
 # Custom Queues configuration
-#
-# We omit :command_builder since Sidekiq::RedisConnection performs a deep clone using
-# Marshal.load(Marshal.dump(options.slice(*keys))) on the Redis config and Gitlab::Redis::CommandBuilder
-# can't be referred to.
-#
-# We do not need the custom command builder since Sidekiq will handle the typing of Redis arguments.
-queues_config_hash = Gitlab::Redis::Queues.params.except(:command_builder)
+queues_config_hash = Gitlab::Redis::Queues.redis_client_params
 
 enable_json_logs = Gitlab.config.sidekiq.log_format != 'text'
 
diff --git a/doc/administration/redis/troubleshooting.md b/doc/administration/redis/troubleshooting.md
index 7ec6414bf45ed..aebb300422356 100644
--- a/doc/administration/redis/troubleshooting.md
+++ b/doc/administration/redis/troubleshooting.md
@@ -124,7 +124,7 @@ To make sure your configuration is correct:
 1. Run in the console:
 
    ```ruby
-   redis = Gitlab::Redis::SharedState.redis
+   redis = Redis.new(Gitlab::Redis::SharedState.params)
    redis.info
    ```
 
diff --git a/doc/development/redis.md b/doc/development/redis.md
index 368f670936792..f9afe5e071890 100644
--- a/doc/development/redis.md
+++ b/doc/development/redis.md
@@ -81,10 +81,10 @@ Developers are highly encouraged to use [hash-tags](https://redis.io/docs/refere
 where appropriate to facilitate future adoption of Redis Cluster in more Redis types. For example, the Namespace model uses hash-tags
 for its [config cache keys](https://gitlab.com/gitlab-org/gitlab/-/blob/1a12337058f260d38405886d82da5e8bb5d8da0b/app/models/namespace.rb#L786).
 
-To perform multi-key commands, developers may use the [`.pipelined`](https://github.com/redis-rb/redis-cluster-client#interfaces) method which splits and sends commands to each node and aggregates replies.
+To perform multi-key commands, developers may use the [`Gitlab::Redis::CrossSlot::Pipeline`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/gitlab/redis/cross_slot.rb) wrapper.
 However, this does not work for [transactions](https://redis.io/docs/interact/transactions/) as Redis Cluster does not support cross-slot transactions.
 
-For `Rails.cache`, we handle the `MGET` command found in `read_multi_get` by [patching it](https://gitlab.com/gitlab-org/gitlab/-/blob/c2bad2aac25e2f2778897bd4759506a72b118b15/lib/gitlab/patch/redis_cache_store.rb#L10) to use the `.pipelined` method.
+For `Rails.cache`, we handle the `MGET` command found in `read_multi_get` by [patching it](https://gitlab.com/gitlab-org/gitlab/-/blob/c2bad2aac25e2f2778897bd4759506a72b118b15/lib/gitlab/patch/redis_cache_store.rb#L10) to use the `Gitlab::Redis::CrossSlot::Pipeline` wrapper.
 The minimum size of the pipeline is set to 1000 commands and it can be adjusted by using the `GITLAB_REDIS_CLUSTER_PIPELINE_BATCH_LIMIT` environment variable.
 
 ## Redis in structured logging
diff --git a/doc/development/redis/new_redis_instance.md b/doc/development/redis/new_redis_instance.md
index 054364fce5e44..ff5394cef8f22 100644
--- a/doc/development/redis/new_redis_instance.md
+++ b/doc/development/redis/new_redis_instance.md
@@ -147,8 +147,8 @@ module Gitlab
         # Don't use multistore if redis.foo configuration is not provided
         return super if config_fallback?
 
-        primary_store = init_redis(params)
-        secondary_store = init_redis(config_fallback.params)
+        primary_store = ::Redis.new(params)
+        secondary_store = ::Redis.new(config_fallback.params)
 
         MultiStore.new(primary_store, secondary_store, store_name)
       end
diff --git a/ee/app/services/elastic/indexing_control_service.rb b/ee/app/services/elastic/indexing_control_service.rb
index 1b82c2f3a76a8..50ececd471062 100644
--- a/ee/app/services/elastic/indexing_control_service.rb
+++ b/ee/app/services/elastic/indexing_control_service.rb
@@ -68,7 +68,7 @@ def resume_processing!
 
         Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do
           if queue_size == 0
-            redis.pipelined do |p|
+            Gitlab::Redis::CrossSlot::Pipeline.new(redis).pipelined do |p|
               p.del(redis_score_key)
               p.del(redis_set_key)
             end
diff --git a/lib/gitlab/background_migration/redis/backfill_project_pipeline_status_ttl.rb b/lib/gitlab/background_migration/redis/backfill_project_pipeline_status_ttl.rb
index 7886abdb6d408..2672498b6271f 100644
--- a/lib/gitlab/background_migration/redis/backfill_project_pipeline_status_ttl.rb
+++ b/lib/gitlab/background_migration/redis/backfill_project_pipeline_status_ttl.rb
@@ -14,7 +14,7 @@ def perform(keys)
           ttl_jitter = 2.hours.to_i
 
           Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do
-            redis.pipelined do |pipeline|
+            Gitlab::Redis::CrossSlot::Pipeline.new(redis).pipelined do |pipeline|
               keys.each { |key| pipeline.expire(key, ttl_duration + rand(-ttl_jitter..ttl_jitter)) }
             end
           end
@@ -25,7 +25,7 @@ def scan_match_pattern
         end
 
         def redis
-          @redis ||= Gitlab::Redis::Cache.redis
+          @redis ||= ::Redis.new(Gitlab::Redis::Cache.params)
         end
       end
     end
diff --git a/lib/gitlab/cache/import/caching.rb b/lib/gitlab/cache/import/caching.rb
index 0b24137076dd6..c538e5162c7b3 100644
--- a/lib/gitlab/cache/import/caching.rb
+++ b/lib/gitlab/cache/import/caching.rb
@@ -139,7 +139,7 @@ def self.set_includes?(raw_key, value)
           key = cache_key_for(raw_key)
 
           with_redis do |redis|
-            redis.sismember(key, value || value.to_s)
+            redis.sismember(key, value)
           end
         end
 
@@ -162,7 +162,7 @@ def self.values_from_set(raw_key)
         def self.write_multiple(mapping, key_prefix: nil, timeout: TIMEOUT)
           with_redis do |redis|
             Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do
-              redis.pipelined do |pipeline|
+              Gitlab::Redis::CrossSlot::Pipeline.new(redis).pipelined do |pipeline|
                 mapping.each do |raw_key, value|
                   key = cache_key_for("#{key_prefix}#{raw_key}")
 
diff --git a/lib/gitlab/diff/highlight_cache.rb b/lib/gitlab/diff/highlight_cache.rb
index 0d5a66175f8e5..dc5f4e1b32432 100644
--- a/lib/gitlab/diff/highlight_cache.rb
+++ b/lib/gitlab/diff/highlight_cache.rb
@@ -197,9 +197,7 @@ def read_cache
         record_hit_ratio(results)
 
         results.map! do |result|
-          unless result.nil?
-            Gitlab::Json.parse(gzip_decompress(result.force_encoding(Encoding::UTF_8)), symbolize_names: true)
-          end
+          Gitlab::Json.parse(gzip_decompress(result), symbolize_names: true) unless result.nil?
         end
 
         file_paths.zip(results).to_h
diff --git a/lib/gitlab/discussions_diff/highlight_cache.rb b/lib/gitlab/discussions_diff/highlight_cache.rb
index dbdc8c3c95c7c..18ff7c28e174b 100644
--- a/lib/gitlab/discussions_diff/highlight_cache.rb
+++ b/lib/gitlab/discussions_diff/highlight_cache.rb
@@ -16,7 +16,7 @@ class << self
         def write_multiple(mapping)
           with_redis do |redis|
             Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do
-              redis.pipelined do |pipelined|
+              Gitlab::Redis::CrossSlot::Pipeline.new(redis).pipelined do |pipelined|
                 mapping.each do |raw_key, value|
                   key = cache_key_for(raw_key)
 
@@ -42,7 +42,7 @@ def read_multiple(raw_keys)
             with_redis do |redis|
               Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do
                 if Gitlab::Redis::ClusterUtil.cluster?(redis)
-                  redis.pipelined do |pipeline|
+                  Gitlab::Redis::CrossSlot::Pipeline.new(redis).pipelined do |pipeline|
                     keys.each { |key| pipeline.get(key) }
                   end
                 else
@@ -54,7 +54,7 @@ def read_multiple(raw_keys)
           content.map! do |lines|
             next unless lines
 
-            Gitlab::Json.parse(gzip_decompress(lines.force_encoding(Encoding::UTF_8))).map! do |line|
+            Gitlab::Json.parse(gzip_decompress(lines)).map! do |line|
               Gitlab::Diff::Line.safe_init_from_hash(line)
             end
           end
diff --git a/lib/gitlab/etag_caching/store.rb b/lib/gitlab/etag_caching/store.rb
index 57c160c9607e7..5fdf5ac943627 100644
--- a/lib/gitlab/etag_caching/store.rb
+++ b/lib/gitlab/etag_caching/store.rb
@@ -17,7 +17,7 @@ def touch(*keys, only_if_missing: false)
 
         Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do
           with_redis do |redis|
-            redis.pipelined do |pipeline|
+            Gitlab::Redis::CrossSlot::Pipeline.new(redis).pipelined do |pipeline|
               keys.each_with_index do |key, i|
                 pipeline.set(redis_shared_state_key(key), etags[i], ex: EXPIRY_TIME, nx: only_if_missing)
               end
diff --git a/lib/gitlab/exclusive_lease.rb b/lib/gitlab/exclusive_lease.rb
index b596956077454..d470fb503cbe2 100644
--- a/lib/gitlab/exclusive_lease.rb
+++ b/lib/gitlab/exclusive_lease.rb
@@ -62,7 +62,6 @@ def self.throttle(key, group: nil, period: 1.hour, count: 1, &block)
 
     def self.cancel(key, uuid)
       return unless key.present?
-      return unless uuid.present?
 
       Gitlab::Redis::SharedState.with do |redis|
         redis.eval(LUA_CANCEL_SCRIPT, keys: [ensure_prefixed_key(key)], argv: [uuid])
@@ -143,7 +142,7 @@ def waiting?
     # false if the lease is taken by a different UUID or inexistent.
     def renew
       Gitlab::Redis::SharedState.with do |redis|
-        result = redis.eval(LUA_RENEW_SCRIPT, keys: [@redis_shared_state_key], argv: [@uuid, @timeout.to_i])
+        result = redis.eval(LUA_RENEW_SCRIPT, keys: [@redis_shared_state_key], argv: [@uuid, @timeout])
         result == @uuid
       end
     end
diff --git a/lib/gitlab/instrumentation/redis_cluster_validator.rb b/lib/gitlab/instrumentation/redis_cluster_validator.rb
index 5c3655f8f803f..948132e6edddb 100644
--- a/lib/gitlab/instrumentation/redis_cluster_validator.rb
+++ b/lib/gitlab/instrumentation/redis_cluster_validator.rb
@@ -1,7 +1,7 @@
 # frozen_string_literal: true
 
 require 'rails'
-require 'redis-clustering'
+require 'redis'
 
 module Gitlab
   module Instrumentation
@@ -230,7 +230,7 @@ def has_cross_slot_keys?(keys)
         end
 
         def key_slot(key)
-          ::RedisClient::Cluster::KeySlotConverter.convert(extract_hash_tag(key))
+          ::Redis::Cluster::KeySlotConverter.convert(extract_hash_tag(key))
         end
 
         # This is almost identical to Redis::Cluster::Command#extract_hash_tag,
diff --git a/lib/gitlab/instrumentation/redis_interceptor.rb b/lib/gitlab/instrumentation/redis_interceptor.rb
new file mode 100644
index 0000000000000..9c89af6a0dc58
--- /dev/null
+++ b/lib/gitlab/instrumentation/redis_interceptor.rb
@@ -0,0 +1,55 @@
+# frozen_string_literal: true
+
+module Gitlab
+  module Instrumentation
+    module RedisInterceptor
+      include RedisHelper
+
+      def call(command)
+        instrument_call([command], instrumentation_class) do
+          super
+        end
+      end
+
+      def call_pipeline(pipeline)
+        instrument_call(pipeline.commands, instrumentation_class, true) do
+          super
+        end
+      end
+
+      def write(command)
+        measure_write_size(command, instrumentation_class) if ::RequestStore.active?
+        super
+      end
+
+      def read
+        result = super
+        measure_read_size(result, instrumentation_class) if ::RequestStore.active?
+        result
+      end
+
+      def ensure_connected
+        super do
+          instrument_reconnection_errors do
+            yield
+          end
+        end
+      end
+
+      def instrument_reconnection_errors
+        yield
+      rescue ::Redis::BaseConnectionError => ex
+        instrumentation_class.instance_count_connection_exception(ex)
+
+        raise ex
+      end
+
+      # That's required so it knows which GitLab Redis instance
+      # it's interacting with in order to categorize accordingly.
+      #
+      def instrumentation_class
+        @options[:instrumentation_class] # rubocop:disable Gitlab/ModuleWithInstanceVariables
+      end
+    end
+  end
+end
diff --git a/lib/gitlab/issues/rebalancing/state.rb b/lib/gitlab/issues/rebalancing/state.rb
index 12cc5f6e5ddf4..c60dac6f571e5 100644
--- a/lib/gitlab/issues/rebalancing/state.rb
+++ b/lib/gitlab/issues/rebalancing/state.rb
@@ -100,7 +100,7 @@ def remove_current_project_id_cache
         def refresh_keys_expiration
           with_redis do |redis|
             Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do
-              redis.pipelined do |pipeline|
+              Gitlab::Redis::CrossSlot::Pipeline.new(redis).pipelined do |pipeline|
                 pipeline.expire(issue_ids_key, REDIS_EXPIRY_TIME)
                 pipeline.expire(current_index_key, REDIS_EXPIRY_TIME)
                 pipeline.expire(current_project_key, REDIS_EXPIRY_TIME)
diff --git a/lib/gitlab/markdown_cache/redis/store.rb b/lib/gitlab/markdown_cache/redis/store.rb
index 85aeaba92c4a5..af9098c3300de 100644
--- a/lib/gitlab/markdown_cache/redis/store.rb
+++ b/lib/gitlab/markdown_cache/redis/store.rb
@@ -11,7 +11,7 @@ def self.bulk_read(subjects)
 
           data = Gitlab::Redis::Cache.with do |r|
             Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do
-              r.pipelined do |pipeline|
+              Gitlab::Redis::CrossSlot::Pipeline.new(r).pipelined do |pipeline|
                 subjects.each do |subject|
                   new(subject).read(pipeline)
                 end
diff --git a/lib/gitlab/patch/command_loader.rb b/lib/gitlab/patch/command_loader.rb
new file mode 100644
index 0000000000000..357b6270b0dd5
--- /dev/null
+++ b/lib/gitlab/patch/command_loader.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Gitlab
+  module Patch
+    module CommandLoader
+      extend ActiveSupport::Concern
+
+      class_methods do
+        # Shuffle the node list to spread out initial connection creation amongst all nodes
+        #
+        # The input is a Redis::Cluster::Node instance which is an Enumerable.
+        #  `super` receives an Array of Redis::Client instead of a Redis::Cluster::Node
+        def load(nodes)
+          super(nodes.to_a.shuffle)
+        end
+      end
+    end
+  end
+end
diff --git a/lib/gitlab/patch/node_loader.rb b/lib/gitlab/patch/node_loader.rb
new file mode 100644
index 0000000000000..85237abc1376b
--- /dev/null
+++ b/lib/gitlab/patch/node_loader.rb
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+
+# Patch to address https://gitlab.com/gitlab-com/gl-infra/scalability/-/issues/2212#note_1287996694
+# It uses hostname instead of IP address if the former is present in `CLUSTER NODES` output.
+if Gem::Version.new(Redis::VERSION) > Gem::Version.new('4.8.1')
+  raise 'New version of redis detected, please remove or update this patch'
+end
+
+module Gitlab
+  module Patch
+    module NodeLoader
+      extend ActiveSupport::Concern
+
+      class_methods do
+        # Shuffle the node list to spread out initial connection creation amongst all nodes
+        #
+        # The input is a Redis::Cluster::Node instance which is an Enumerable.
+        #  `super` receives an Array of Redis::Client instead of a Redis::Cluster::Node
+        def load_flags(nodes)
+          super(nodes.to_a.shuffle)
+        end
+      end
+
+      def self.prepended(base)
+        base.class_eval do
+          # monkey-patches https://github.com/redis/redis-rb/blob/v4.8.0/lib/redis/cluster/node_loader.rb#L23
+          def self.fetch_node_info(node)
+            node.call(%i[cluster nodes]).split("\n").map(&:split).to_h do |arr|
+              [
+                extract_host_identifier(arr[1]),
+                (arr[2].split(',') & %w[master slave]).first # rubocop:disable Naming/InclusiveLanguage
+              ]
+            end
+          end
+
+          # Since `CLUSTER SLOT` uses the preferred endpoint determined by
+          # the `cluster-preferred-endpoint-type` config value, we will prefer hostname over IP address.
+          # See https://redis.io/commands/cluster-nodes/ for details on the output format.
+          #
+          # @param [String] Address info matching fhe format: <ip:port@cport[,hostname[,auxiliary_field=value]*]>
+          def self.extract_host_identifier(node_address)
+            ip_chunk, hostname, _auxiliaries = node_address.split(',')
+            return ip_chunk.split('@').first if hostname.blank?
+
+            port = ip_chunk.split('@').first.split(':')[1]
+            "#{hostname}:#{port}"
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/lib/gitlab/patch/redis_cache_store.rb b/lib/gitlab/patch/redis_cache_store.rb
index 35c617ba72b1c..96729056ce5f7 100644
--- a/lib/gitlab/patch/redis_cache_store.rb
+++ b/lib/gitlab/patch/redis_cache_store.rb
@@ -20,7 +20,7 @@ def delete_multi_entries(entries, **options)
         delete_count = 0
         redis.with do |conn|
           entries.each_slice(pipeline_batch_size) do |subset|
-            delete_count += conn.pipelined do |pipeline|
+            delete_count += Gitlab::Redis::CrossSlot::Pipeline.new(conn).pipelined do |pipeline|
               subset.each { |entry| pipeline.del(entry) }
             end.sum
           end
@@ -58,7 +58,7 @@ def patched_read_multi_mget(*names)
 
       def pipeline_mget(conn, keys)
         keys.each_slice(pipeline_batch_size).flat_map do |subset|
-          conn.pipelined do |p|
+          Gitlab::Redis::CrossSlot::Pipeline.new(conn).pipelined do |p|
             subset.each { |key| p.get(key) }
           end
         end
diff --git a/lib/gitlab/patch/redis_cluster.rb b/lib/gitlab/patch/redis_cluster.rb
new file mode 100644
index 0000000000000..145ce35a31736
--- /dev/null
+++ b/lib/gitlab/patch/redis_cluster.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+# Patch to expose `find_node_key` method for cross-slot pipelining
+# In redis v5.0.x, cross-slot pipelining is implemented via redis-cluster-client.
+# This patch should be removed since there is no need for it.
+# Gitlab::Redis::CrossSlot and its usage should be removed as well.
+if Gem::Version.new(Redis::VERSION) != Gem::Version.new('4.8.0')
+  raise 'New version of redis detected, please remove or update this patch'
+end
+
+module Gitlab
+  module Patch
+    module RedisCluster
+      # _find_node_key exposes a private function of the same name in Redis::Cluster.
+      # See https://github.com/redis/redis-rb/blob/v4.8.0/lib/redis/cluster.rb#L282
+      def _find_node_key(command)
+        find_node_key(command)
+      end
+    end
+  end
+end
diff --git a/lib/gitlab/patch/redis_store_factory.rb b/lib/gitlab/patch/redis_store_factory.rb
deleted file mode 100644
index 81636ed665dc5..0000000000000
--- a/lib/gitlab/patch/redis_store_factory.rb
+++ /dev/null
@@ -1,16 +0,0 @@
-# frozen_string_literal: true
-
-module Gitlab
-  module Patch
-    module RedisStoreFactory
-      def create
-        # rubocop:disable Gitlab/ModuleWithInstanceVariables -- patched code references @options in redis-store
-        opt = @options
-        # rubocop:enable Gitlab/ModuleWithInstanceVariables
-        return Gitlab::Redis::ClusterStore.new(opt) if opt[:nodes]
-
-        super
-      end
-    end
-  end
-end
diff --git a/lib/gitlab/patch/slot_loader.rb b/lib/gitlab/patch/slot_loader.rb
new file mode 100644
index 0000000000000..e302d844078d7
--- /dev/null
+++ b/lib/gitlab/patch/slot_loader.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Gitlab
+  module Patch
+    module SlotLoader
+      extend ActiveSupport::Concern
+
+      class_methods do
+        # Shuffle the node list to spread out initial connection creation amongst all nodes
+        #
+        # The input is a Redis::Cluster::Node instance which is an Enumerable.
+        #  `super` receives an Array of Redis::Client instead of a Redis::Cluster::Node
+        def load(nodes)
+          super(nodes.to_a.shuffle)
+        end
+      end
+    end
+  end
+end
diff --git a/lib/gitlab/redis/cluster_store.rb b/lib/gitlab/redis/cluster_store.rb
deleted file mode 100644
index d660c782d4d01..0000000000000
--- a/lib/gitlab/redis/cluster_store.rb
+++ /dev/null
@@ -1,81 +0,0 @@
-# frozen_string_literal: true
-
-require 'redis-clustering'
-require 'redis/store/ttl'
-require 'redis/store/interface'
-require 'redis/store/namespace'
-require 'redis/store/serialization'
-
-module Gitlab
-  module Redis
-    class ClusterStore < ::Redis::Cluster
-      include ::Redis::Store::Interface
-
-      def initialize(options = {})
-        orig_options = options.dup
-
-        @serializer = orig_options.key?(:serializer) ? orig_options.delete(:serializer) : Marshal
-
-        unless orig_options[:marshalling].nil?
-          # `marshalling` only used here, might not be supported in `super`
-          @serializer = orig_options.delete(:marshalling) ? Marshal : nil
-        end
-
-        _remove_unsupported_options(options)
-        super(options)
-
-        _extend_marshalling
-        _extend_namespace orig_options
-      end
-
-      # copies ::Redis::Store::Ttl implementation in a redis-v5 compatible manner
-      def set(key, value, options = nil)
-        ttl = get_ttl(options)
-        if ttl
-          setex(key, ttl.to_i, value, raw: true)
-        else
-          super(key, value)
-        end
-      end
-
-      # copies ::Redis::Store::Ttl implementation in a redis-v5 compatible manner
-      def setnx(key, value, options = nil)
-        ttl = get_ttl(options)
-        if ttl
-          multi do |m|
-            m.setnx(key, value)
-            m.expire(key, ttl)
-          end
-        else
-          super(key, value)
-        end
-      end
-
-      private
-
-      def get_ttl(options)
-        # https://github.com/redis-store/redis-store/blob/v1.10.0/lib/redis/store/ttl.rb#L37
-        options[:expire_after] || options[:expires_in] || options[:expire_in] if options
-      end
-
-      def _remove_unsupported_options(options)
-        # Unsupported keywords should be removed to avoid errors
-        # https://github.com/redis-rb/redis-client/blob/v0.13.0/lib/redis_client/config.rb#L21
-        options.delete(:raw)
-        options.delete(:serializer)
-        options.delete(:marshalling)
-        options.delete(:namespace)
-        options.delete(:scheme)
-      end
-
-      def _extend_marshalling
-        extend ::Redis::Store::Serialization unless @serializer.nil?
-      end
-
-      def _extend_namespace(options)
-        @namespace = options[:namespace]
-        extend ::Redis::Store::Namespace
-      end
-    end
-  end
-end
diff --git a/lib/gitlab/redis/cluster_util.rb b/lib/gitlab/redis/cluster_util.rb
index 99f337749d0d8..9e307940de3d1 100644
--- a/lib/gitlab/redis/cluster_util.rb
+++ b/lib/gitlab/redis/cluster_util.rb
@@ -13,14 +13,14 @@ def cluster?(obj)
           if obj.is_a?(MultiStore)
             cluster?(obj.primary_store) || cluster?(obj.secondary_store)
           else
-            obj.is_a?(::Redis::Cluster)
+            obj.respond_to?(:_client) && obj._client.is_a?(::Redis::Cluster)
           end
         end
 
         def batch_unlink(keys, redis)
           expired_count = 0
           keys.each_slice(1000) do |subset|
-            expired_count += redis.pipelined do |pipeline|
+            expired_count += Gitlab::Redis::CrossSlot::Pipeline.new(redis).pipelined do |pipeline|
               subset.each { |key| pipeline.unlink(key) }
             end.sum
           end
@@ -30,7 +30,7 @@ def batch_unlink(keys, redis)
         # Redis cluster alternative to mget
         def batch_get(keys, redis)
           keys.each_slice(1000).flat_map do |subset|
-            redis.pipelined do |pipeline|
+            Gitlab::Redis::CrossSlot::Pipeline.new(redis).pipelined do |pipeline|
               subset.map { |key| pipeline.get(key) }
             end
           end
diff --git a/lib/gitlab/redis/command_builder.rb b/lib/gitlab/redis/command_builder.rb
deleted file mode 100644
index 99d26760b3d37..0000000000000
--- a/lib/gitlab/redis/command_builder.rb
+++ /dev/null
@@ -1,53 +0,0 @@
-# frozen_string_literal: true
-
-module Gitlab
-  module Redis
-    module CommandBuilder
-      extend self
-
-      # Ref: https://github.com/redis-rb/redis-client/blob/v0.19.1/lib/redis_client/command_builder.rb
-      # we modify the command builder to convert nil to strings as this behaviour was present in
-      # https://github.com/redis/redis-rb/blob/v4.8.0/lib/redis/connection/command_helper.rb#L20
-      #
-      # Note that we only adopt the Ruby3.x-compatible logic in .generate.
-      # Symbol.method_defined?(:name) is true in Ruby 3
-      def generate(args, kwargs = nil)
-        command = args.flat_map do |element|
-          case element
-          when Hash
-            element.flatten
-          else
-            element
-          end
-        end
-
-        kwargs&.each do |key, value|
-          if value
-            if value == true
-              command << key.name
-            else
-              command << key.name << value
-            end
-          end
-        end
-
-        command.map! do |element|
-          case element
-          when String
-            element
-          when Symbol
-            element.name
-          when Integer, Float, NilClass
-            element.to_s
-          else
-            raise TypeError, "Unsupported command argument type: #{element.class}"
-          end
-        end
-
-        raise ArgumentError, "can't issue an empty redis command" if command.empty?
-
-        command
-      end
-    end
-  end
-end
diff --git a/lib/gitlab/redis/cross_slot.rb b/lib/gitlab/redis/cross_slot.rb
new file mode 100644
index 0000000000000..e5aa6d9ce7246
--- /dev/null
+++ b/lib/gitlab/redis/cross_slot.rb
@@ -0,0 +1,141 @@
+# frozen_string_literal: true
+
+module Gitlab
+  module Redis
+    module CrossSlot
+      class Router
+        attr_reader :node_mapping, :futures, :node_sequence, :cmd_queue
+
+        delegate :respond_to_missing?, to: :@redis
+
+        # This map contains redis-rb methods which does not map directly
+        # to a standard Redis command. It is used transform unsupported commands to standard commands
+        # to find the node key for unsupported commands.
+        #
+        # Redis::Cluster::Command only contains details of commands which the Redis Server
+        # returns. Hence, commands like mapped_hmget and hscan_each internally will call the
+        # base command, hmget and hscan respectively.
+        #
+        # See https://github.com/redis/redis-rb/blob/v4.8.0/lib/redis/cluster/command.rb
+        UNSUPPORTED_CMD_MAPPING = {
+          # Internally, redis-rb calls the supported Redis command and transforms the output.
+          # See https://github.com/redis/redis-rb/blob/v4.8.0/lib/redis/commands/hashes.rb#L104
+          mapped_hmget: :hmget
+        }.freeze
+
+        # Initializes the CrossSlot::Router
+        # @param {::Redis}
+        def initialize(redis)
+          @redis = redis
+          @node_mapping = {}
+          @futures = {}
+          @node_sequence = []
+          @cmd_queue = []
+        end
+
+        # For now we intercept every redis.call and return a Gitlab-Future object.
+        # This method groups every commands to a node for fan-out. Commands are grouped using the first key.
+        #
+        # rubocop:disable Style/MissingRespondToMissing
+        def method_missing(cmd, *args, **kwargs, &blk)
+          # Note that we can re-map the command without affecting execution as it is
+          # solely for finding the node key. The original cmd will be executed.
+          node = @redis._client._find_node_key([UNSUPPORTED_CMD_MAPPING.fetch(cmd, cmd)] + args)
+
+          @node_mapping[node] ||= []
+          @futures[node] ||= []
+
+          @node_sequence << node
+          @node_mapping[node] << [cmd, args, kwargs || {}, blk]
+          f = Future.new
+          @futures[node] << f
+          @cmd_queue << [f, cmd, args, kwargs || {}, blk]
+          f
+        end
+        # rubocop:enable Style/MissingRespondToMissing
+      end
+
+      # Wraps over redis-rb's Future in
+      # https://github.com/redis/redis-rb/blob/v4.8.0/lib/redis/pipeline.rb#L244
+      class Future
+        def set(future, is_val = false)
+          @redis_future = future
+          @is_val = is_val
+        end
+
+        def value
+          return @redis_val if @is_val
+
+          @redis_future.value
+        end
+      end
+
+      # Pipeline allows cross-slot pipelined to be called. The fan-out logic is implemented in
+      # https://github.com/redis-rb/redis-cluster-client/blob/master/lib/redis_client/cluster/pipeline.rb
+      # which is available in redis-rb v5.0.
+      #
+      # This file can be deprecated after redis-rb v4.8.0 is upgraded to v5.0
+      class Pipeline
+        # Initializes the CrossSlot::Pipeline
+        # @param {::Redis}
+        def initialize(redis)
+          @redis = redis
+        end
+
+        # pipelined is used in place of ::Redis `.pipelined` when running in a cluster context
+        # where cross-slot operations may happen.
+        def pipelined(&block)
+          # Directly call .pipelined and defer the pipeline execution to MultiStore.
+          # MultiStore could wrap over 0, 1, or 2 Redis Cluster clients, handling it here
+          # will not work for 2 clients since the key-slot topology can differ.
+          if use_cross_slot_pipelining?
+            router = Router.new(@redis)
+            yield router
+            execute_commands(router)
+          else
+            # use redis-rb's pipelined method
+            @redis.pipelined(&block)
+          end
+        end
+
+        private
+
+        def use_cross_slot_pipelining?
+          !@redis.instance_of?(::Gitlab::Redis::MultiStore) && @redis._client.instance_of?(::Redis::Cluster)
+        end
+
+        def execute_commands(router)
+          router.node_mapping.each do |node_key, commands|
+            # TODO possibly use Threads to speed up but for now `n` is 3-5 which is small.
+            @redis.pipelined do |p|
+              commands.each_with_index do |command, idx|
+                future = router.futures[node_key][idx]
+                cmd, args, kwargs, blk = command
+                future.set(p.public_send(cmd, *args, **kwargs, &blk)) # rubocop:disable GitlabSecurity/PublicSend
+              end
+            end
+          end
+
+          router.node_sequence.map do |node_key|
+            router.futures[node_key].shift.value
+          end
+        rescue ::Redis::CommandError => err
+          if err.message.start_with?('MOVED', 'ASK')
+            Gitlab::ErrorTracking.log_exception(err)
+            return execute_commands_sequentially(router)
+          end
+
+          raise
+        end
+
+        def execute_commands_sequentially(router)
+          router.cmd_queue.map do |command|
+            future, cmd, args, kwargs, blk = command
+            future.set(@redis.public_send(cmd, *args, **kwargs, &blk), true) # rubocop:disable GitlabSecurity/PublicSend
+            future.value
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/lib/gitlab/redis/multi_store.rb b/lib/gitlab/redis/multi_store.rb
index 476077cb96f0d..e6262f7f61b51 100644
--- a/lib/gitlab/redis/multi_store.rb
+++ b/lib/gitlab/redis/multi_store.rb
@@ -432,6 +432,16 @@ def same_redis_store?
 
       # rubocop:disable GitlabSecurity/PublicSend
       def send_command(redis_instance, command_name, *args, **kwargs, &block)
+        # Run wrapped pipeline for each instance individually so that the fan-out is distinct.
+        # If both primary and secondary are Redis Clusters, the slot-node distribution could
+        # be different.
+        #
+        # We ignore args and kwargs since `pipelined` does not accept arguments
+        # See https://github.com/redis/redis-rb/blob/v4.8.0/lib/redis.rb#L164
+        if command_name.to_s == 'pipelined' && redis_instance._client.instance_of?(::Redis::Cluster)
+          return Gitlab::Redis::CrossSlot::Pipeline.new(redis_instance).pipelined(&block)
+        end
+
         if block
           # Make sure that block is wrapped and executed only on the redis instance that is executing the block
           redis_instance.send(command_name, *args, **kwargs) do |*params|
@@ -452,7 +462,7 @@ def with_instance(instance, *params)
       end
 
       def redis_store?(pool)
-        pool.with { |c| c.instance_of?(Gitlab::Redis::MultiStore) || c.is_a?(::Redis) || c.is_a?(::Redis::Cluster) }
+        pool.with { |c| c.instance_of?(Gitlab::Redis::MultiStore) || c.is_a?(::Redis) }
       end
 
       def validate_stores!
diff --git a/lib/gitlab/redis/wrapper.rb b/lib/gitlab/redis/wrapper.rb
index f7a7e703d90e7..a106ed3884ef3 100644
--- a/lib/gitlab/redis/wrapper.rb
+++ b/lib/gitlab/redis/wrapper.rb
@@ -20,7 +20,7 @@ class Wrapper
       CommandExecutionError = Class.new(StandardError)
 
       class << self
-        delegate :params, :url, :store, :encrypted_secrets, to: :new
+        delegate :params, :url, :store, :encrypted_secrets, :redis_client_params, to: :new
 
         def with
           pool.with { |redis| yield redis }
@@ -90,17 +90,7 @@ def instrumentation_class
         end
 
         def redis
-          init_redis(params)
-        end
-
-        private
-
-        def init_redis(config)
-          if config[:nodes].present?
-            ::Redis::Cluster.new(config.merge({ concurrency: { model: :none } }))
-          else
-            ::Redis.new(config)
-          end
+          ::Redis.new(params)
         end
       end
 
@@ -109,8 +99,13 @@ def initialize(rails_env = nil)
       end
 
       def params
+        redis_store_options
+      end
+
+      # redis_client_params modifies redis_store_options to be compatible with redis-client
+      # TODO: when redis-rb is updated to v5, there is no need to support 2 types of config format
+      def redis_client_params
         options = redis_store_options
-        options[:command_builder] = CommandBuilder
 
         # avoid passing classes into options as Sidekiq scrubs the options with Marshal.dump + Marshal.load
         # ref https://github.com/sidekiq/sidekiq/blob/v7.1.6/lib/sidekiq/redis_connection.rb#L37
@@ -119,14 +114,14 @@ def params
         # we use strings to look up Gitlab::Instrumentation::Redis.storage_hash as a bypass
         options[:custom] = { instrumentation_class: self.class.store_name }
 
+        # TODO: add support for cluster when upgrading to redis-rb v5.y.z we do not need cluster support
+        # as Sidekiq workload should not and does not run in a Redis Cluster
+        # support to be added in https://gitlab.com/gitlab-org/gitlab/-/merge_requests/134862
         if options[:sentinels]
           # name is required in RedisClient::SentinelConfig
           # https://github.com/redis-rb/redis-client/blob/1ab081c1d0e47df5d55e011c9390c70b2eef6731/lib/redis_client/sentinel_config.rb#L17
           options[:name] = options[:host]
           options.except(:scheme, :instrumentation_class, :host, :port)
-        elsif options[:cluster]
-          options[:nodes] = options[:cluster].map { |c| c.except(:scheme) }
-          options.except(:scheme, :instrumentation_class, :cluster)
         else
           # remove disallowed keys as seen in
           # https://github.com/redis-rb/redis-client/blob/1ab081c1d0e47df5d55e011c9390c70b2eef6731/lib/redis_client/config.rb#L21
@@ -139,7 +134,7 @@ def url
       end
 
       def db
-        redis_store_options[:db] || 0
+        redis_store_options[:db]
       end
 
       def sentinels
@@ -161,7 +156,7 @@ def sentinels?
       end
 
       def store(extras = {})
-        ::Redis::Store::Factory.create(params.merge(extras))
+        ::Redis::Store::Factory.create(redis_store_options.merge(extras))
       end
 
       def encrypted_secrets
@@ -187,11 +182,7 @@ def redis_store_options
         final_config = parse_extra_config(decrypted_config)
 
         result = if final_config[:cluster].present?
-                   final_config[:cluster] = final_config[:cluster].map do |node|
-                     next node unless node.is_a?(String)
-
-                     ::Redis::Store::Factory.extract_host_options_from_uri(node)
-                   end
+                   final_config[:db] = 0 # Redis Cluster only supports db 0
                    final_config
                  else
                    parse_redis_url(final_config)
diff --git a/lib/gitlab/repository_hash_cache.rb b/lib/gitlab/repository_hash_cache.rb
index 6da5556833f2e..fab0e9e09e8da 100644
--- a/lib/gitlab/repository_hash_cache.rb
+++ b/lib/gitlab/repository_hash_cache.rb
@@ -86,8 +86,6 @@ def write(key, hash)
 
       full_key = cache_key(key)
 
-      hash = standardize_hash(hash)
-
       with do |redis|
         results = redis.pipelined do |pipeline|
           # Set each hash key to the provided value
diff --git a/lib/gitlab/set_cache.rb b/lib/gitlab/set_cache.rb
index 967a139e7fb3c..eb73a0a3d31eb 100644
--- a/lib/gitlab/set_cache.rb
+++ b/lib/gitlab/set_cache.rb
@@ -62,7 +62,7 @@ def try_include?(key, value)
 
       with do |redis|
         redis.multi do |multi|
-          multi.sismember(full_key, value.to_s)
+          multi.sismember(full_key, value)
           multi.exists?(full_key) # rubocop:disable CodeReuse/ActiveRecord
         end
       end
diff --git a/lib/gitlab/sidekiq_middleware/duplicate_jobs/duplicate_job.rb b/lib/gitlab/sidekiq_middleware/duplicate_jobs/duplicate_job.rb
index c40f125db40d4..883e1ba0558dd 100644
--- a/lib/gitlab/sidekiq_middleware/duplicate_jobs/duplicate_job.rb
+++ b/lib/gitlab/sidekiq_middleware/duplicate_jobs/duplicate_job.rb
@@ -75,7 +75,7 @@ def update_latest_wal_location!
           argv = []
           job_wal_locations.each do |connection_name, location|
             diff = pg_wal_lsn_diff(connection_name)
-            argv += [connection_name, diff ? diff.to_f : '', location]
+            argv += [connection_name, diff || '', location]
           end
 
           with_redis { |r| r.eval(UPDATE_WAL_COOKIE_SCRIPT, keys: [cookie_key], argv: argv) }
diff --git a/spec/controllers/concerns/product_analytics_tracking_spec.rb b/spec/controllers/concerns/product_analytics_tracking_spec.rb
index 247be5429a1df..7b48782be989e 100644
--- a/spec/controllers/concerns/product_analytics_tracking_spec.rb
+++ b/spec/controllers/concerns/product_analytics_tracking_spec.rb
@@ -109,8 +109,7 @@ def expect_no_internal_tracking
       end
 
       it 'tracks total Redis counters' do
-        expect(Gitlab::Usage::Metrics::Instrumentations::TotalCountMetric).to receive(:redis_key)
-          .twice.and_call_original # total and 7d
+        expect(Gitlab::Usage::Metrics::Instrumentations::TotalCountMetric).to receive(:redis_key).twice # total and 7d
 
         get :index
       end
diff --git a/spec/fixtures/config/redis_new_format_host_standalone.yml b/spec/fixtures/config/redis_new_format_host_standalone.yml
deleted file mode 100644
index f583658624738..0000000000000
--- a/spec/fixtures/config/redis_new_format_host_standalone.yml
+++ /dev/null
@@ -1,8 +0,0 @@
-# redis://[:password@]host[:port][/db-number][?option=value]
-# more details: http://www.iana.org/assignments/uri-schemes/prov/redis
-development:
-  url: redis://:mynewpassword@development-host:6379/99
-test:
-  url: redis://:mynewpassword@test-host:6379/99
-production:
-  url: redis://:mynewpassword@production-host:6379/99
diff --git a/spec/initializers/action_cable_subscription_adapter_identifier_spec.rb b/spec/initializers/action_cable_subscription_adapter_identifier_spec.rb
index d89ad21ca82bd..cf82fd751ddea 100644
--- a/spec/initializers/action_cable_subscription_adapter_identifier_spec.rb
+++ b/spec/initializers/action_cable_subscription_adapter_identifier_spec.rb
@@ -27,7 +27,7 @@
 
         sub = ActionCable.server.pubsub.send(:redis_connection)
 
-        expect(sub.connection[:id]).to eq('/home/localuser/redis/redis.socket/0')
+        expect(sub.connection[:id]).to eq('unix:///home/localuser/redis/redis.socket/0')
         expect(ActionCable.server.config.cable[:id]).to be_nil
       end
     end
diff --git a/spec/initializers/session_store_spec.rb b/spec/initializers/session_store_spec.rb
index bd943830f6e87..c9333d022dd31 100644
--- a/spec/initializers/session_store_spec.rb
+++ b/spec/initializers/session_store_spec.rb
@@ -15,7 +15,7 @@
 
   describe 'config#session_store' do
     it 'initialized as a redis_store with a proper servers configuration' do
-      expect(subject).to receive(:session_store).with(:redis_store, a_hash_including(redis_server: Gitlab::Redis::Sessions.params.merge(namespace: Gitlab::Redis::Sessions::SESSION_NAMESPACE)))
+      expect(subject).to receive(:session_store).with(:redis_store, a_hash_including(redis_store: kind_of(::Redis::Store)))
 
       load_session_store
     end
diff --git a/spec/lib/click_house/migration_support/exclusive_lock_spec.rb b/spec/lib/click_house/migration_support/exclusive_lock_spec.rb
index 17e58a4ddef60..5176cc752666a 100644
--- a/spec/lib/click_house/migration_support/exclusive_lock_spec.rb
+++ b/spec/lib/click_house/migration_support/exclusive_lock_spec.rb
@@ -5,8 +5,6 @@
 RSpec.describe ClickHouse::MigrationSupport::ExclusiveLock, feature_category: :database do
   include ExclusiveLeaseHelpers
 
-  let(:worker_id) { 1 }
-
   let(:worker_class) do
     # This worker will be active longer than the ClickHouse worker TTL
     Class.new do
@@ -83,7 +81,7 @@ def migration
       end
 
       around do |example|
-        described_class.register_running_worker(worker_class, worker_id) do
+        described_class.register_running_worker(worker_class, anything) do
           example.run
         end
       end
diff --git a/spec/lib/gitlab/background_migration/redis/backfill_project_pipeline_status_ttl_spec.rb b/spec/lib/gitlab/background_migration/redis/backfill_project_pipeline_status_ttl_spec.rb
index a55ae5711a8d0..c52d1b4c9f2a5 100644
--- a/spec/lib/gitlab/background_migration/redis/backfill_project_pipeline_status_ttl_spec.rb
+++ b/spec/lib/gitlab/background_migration/redis/backfill_project_pipeline_status_ttl_spec.rb
@@ -4,7 +4,7 @@
 
 RSpec.describe Gitlab::BackgroundMigration::Redis::BackfillProjectPipelineStatusTtl,
   :clean_gitlab_redis_cache, feature_category: :redis do
-  let(:redis) { ::Gitlab::Redis::Cache.redis }
+  let(:redis) { ::Redis.new(::Gitlab::Redis::Cache.params) }
   let(:keys) { ["cache:gitlab:project:1:pipeline_status", "cache:gitlab:project:2:pipeline_status"] }
   let(:invalid_keys) { ["cache:gitlab:project:pipeline_status:1", "cache:gitlab:project:pipeline_status:2"] }
 
diff --git a/spec/lib/gitlab/database/migrations/runner_backoff/communicator_spec.rb b/spec/lib/gitlab/database/migrations/runner_backoff/communicator_spec.rb
index 9f1ade529b182..cfc3fb398e24e 100644
--- a/spec/lib/gitlab/database/migrations/runner_backoff/communicator_spec.rb
+++ b/spec/lib/gitlab/database/migrations/runner_backoff/communicator_spec.rb
@@ -28,7 +28,7 @@
 
     it 'reads from Redis' do
       recorder = RedisCommands::Recorder.new { subject }
-      expect(recorder.log).to include(['exists', 'gitlab:exclusive_lease:gitlab/database/migration/runner/backoff'])
+      expect(recorder.log).to include([:exists, 'gitlab:exclusive_lease:gitlab/database/migration/runner/backoff'])
     end
 
     context 'with runner_migrations_backoff disabled' do
diff --git a/spec/lib/gitlab/discussions_diff/highlight_cache_spec.rb b/spec/lib/gitlab/discussions_diff/highlight_cache_spec.rb
index 8068be798d392..30981e4bd7d11 100644
--- a/spec/lib/gitlab/discussions_diff/highlight_cache_spec.rb
+++ b/spec/lib/gitlab/discussions_diff/highlight_cache_spec.rb
@@ -33,7 +33,7 @@ def fake_file(offset)
 
       mapping.each do |key, value|
         full_key = described_class.cache_key_for(key)
-        found_key = Gitlab::Redis::Cache.with { |r| r.get(full_key).force_encoding("UTF-8") }
+        found_key = Gitlab::Redis::Cache.with { |r| r.get(full_key) }
 
         expect(described_class.gzip_decompress(found_key)).to eq(value.to_json)
       end
diff --git a/spec/lib/gitlab/external_authorization/cache_spec.rb b/spec/lib/gitlab/external_authorization/cache_spec.rb
index 3dc23422e4edc..186bf7d7ec15f 100644
--- a/spec/lib/gitlab/external_authorization/cache_spec.rb
+++ b/spec/lib/gitlab/external_authorization/cache_spec.rb
@@ -23,9 +23,9 @@ def set_in_redis(key, value)
   describe '#load' do
     it 'reads stored info from redis' do
       freeze_time do
-        set_in_redis(:access, false.to_s)
+        set_in_redis(:access, false)
         set_in_redis(:reason, 'Access denied for now')
-        set_in_redis(:refreshed_at, Time.now.to_s)
+        set_in_redis(:refreshed_at, Time.now)
 
         access, reason, refreshed_at = cache.load
 
diff --git a/spec/lib/gitlab/instrumentation/redis_client_middleware_spec.rb b/spec/lib/gitlab/instrumentation/redis_client_middleware_spec.rb
index 2eb77add2ed69..a8bded69696e6 100644
--- a/spec/lib/gitlab/instrumentation/redis_client_middleware_spec.rb
+++ b/spec/lib/gitlab/instrumentation/redis_client_middleware_spec.rb
@@ -9,9 +9,10 @@
   include RedisHelpers
 
   let_it_be(:redis_store_class) { define_helper_redis_store_class }
+  let_it_be(:redis_client) { RedisClient.new(redis_store_class.redis_client_params) }
 
   before do
-    redis_store_class.with(&:flushdb)
+    redis_client.call("flushdb")
   end
 
   describe 'read and write' do
@@ -23,30 +24,27 @@
       # The response is 1001, so 4 bytes. Exercise counting an integer reply.
       [[:set, 'foobar', 1000]] | [:incr, 'foobar'] | (4 + 6) | 4
 
-      # Exercise counting empty multi bulk reply.
-      [] | [:hgetall, 'foobar'] | (7 + 6) | 0
+      # Exercise counting empty multi bulk reply. Returns an empty hash `{}`
+      [] | [:hgetall, 'foobar'] | (7 + 6) | 2
 
       # Hgetall response length is combined length of keys and values in the
       # hash. Exercises counting of a multi bulk reply
-      [[:hset, 'myhash', 'field', 'hello world']] | [:hgetall, 'myhash'] | (7 + 6) | (5 + 11)
+      # Returns `{"field"=>"hello world"}`, 5 for field, 11 for hello world, 8 for {, }, 4 "s, =, >
+      [[:hset, 'myhash', 'field', 'hello world']] | [:hgetall, 'myhash'] | (7 + 6) | (5 + 11 + 8)
 
       # Exercise counting of a bulk reply
       [[:set, 'foo', 'bar' * 100]] | [:get, 'foo'] | (3 + 3) | (3 * 100)
 
-      # Nested array response: [['foo', 0], ['bar', 1.1000000000000001]] due to Redis precision
-      # See https://github.com/redis/redis/issues/1499
+      # Nested array response: [['foo', 0.0], ['bar', 1.0]]. Returns scores as float.
       [[:zadd, 'myset', 0, 'foo'],
-        [:zadd, 'myset', 1.1,
-          'bar']] | [:zrange, 'myset', 0, -1, 'withscores'] | (6 + 5 + 1 + 2 + 10) | (3 + 1 + 3 + 18)
+        [:zadd, 'myset', 1, 'bar']] | [:zrange, 'myset', 0, -1, 'withscores'] | (6 + 5 + 1 + 2 + 10) | (3 + 3 + 3 + 3)
     end
 
     with_them do
       it 'counts bytes read and written' do
-        redis_store_class.with do |redis|
-          setup.each { |cmd| redis.call(cmd) }
-          RequestStore.clear!
-          redis.call(command)
-        end
+        setup.each { |cmd| redis_client.call(*cmd) }
+        RequestStore.clear!
+        redis_client.call(*command)
 
         expect(Gitlab::Instrumentation::Redis.read_bytes).to eq(expect_read)
         expect(Gitlab::Instrumentation::Redis.write_bytes).to eq(expect_write)
@@ -60,48 +58,35 @@
     it 'counts successful requests' do
       expect(instrumentation_class).to receive(:instance_count_request).with(1).and_call_original
 
-      redis_store_class.with { |redis| redis.call(:get, 'foobar') }
+      redis_client.call(:get, 'foobar')
     end
 
     it 'counts successful pipelined requests' do
       expect(instrumentation_class).to receive(:instance_count_request).with(2).and_call_original
       expect(instrumentation_class).to receive(:instance_count_pipelined_request).with(2).and_call_original
 
-      redis_store_class.with do |redis|
-        redis.pipelined do |pipeline|
-          pipeline.call(:get, '{foobar}buz')
-          pipeline.call(:get, '{foobar}baz')
-        end
+      redis_client.pipelined do |pipeline|
+        pipeline.call(:get, '{foobar}buz')
+        pipeline.call(:get, '{foobar}baz')
       end
     end
 
     context 'when encountering exceptions' do
-      where(:case_name, :exception, :exception_counter) do
-        'generic exception' | Redis::CommandError.new                             | :instance_count_exception
-        'moved redirection' | Redis::CommandError.new("MOVED 123 127.0.0.1:6380") | :instance_count_cluster_redirection
-        'ask redirection'   | Redis::CommandError.new("ASK 123 127.0.0.1:6380")   | :instance_count_cluster_redirection
+      before do
+        allow(redis_client.instance_variable_get(:@raw_connection)).to receive(:call).and_raise(
+          RedisClient::Error)
       end
 
-      with_them do
-        before do
-          redis_store_class.with do |redis|
-            # We need to go 1 layer deeper to stub _client as we monkey-patch Redis::Client
-            # with the interceptor. Stubbing `redis` will skip the instrumentation_class.
-            allow(redis._client.instance_variable_get(:@raw_connection)).to receive(:call).and_raise(exception)
-          end
-        end
-
-        it 'counts exception' do
-          expect(instrumentation_class).to receive(exception_counter)
-                                             .with(instance_of(Redis::CommandError)).and_call_original
-          expect(instrumentation_class).to receive(:log_exception)
-                                             .with(instance_of(Redis::CommandError)).and_call_original
-          expect(instrumentation_class).to receive(:instance_count_request).and_call_original
+      it 'counts exception' do
+        expect(instrumentation_class).to receive(:instance_count_exception)
+                                           .with(instance_of(RedisClient::Error)).and_call_original
+        expect(instrumentation_class).to receive(:log_exception)
+                                           .with(instance_of(RedisClient::Error)).and_call_original
+        expect(instrumentation_class).to receive(:instance_count_request).and_call_original
 
-          expect do
-            redis_store_class.with { |redis| redis.call(:auth, 'foo', 'bar') }
-          end.to raise_exception(Redis::CommandError)
-        end
+        expect do
+          redis_client.call(:auth, 'foo', 'bar')
+        end.to raise_error(RedisClient::Error)
       end
     end
 
@@ -114,7 +99,7 @@
         expect(instrumentation_class).to receive(:increment_cross_slot_request_count).and_call_original
         expect(instrumentation_class).not_to receive(:increment_allowed_cross_slot_request_count).and_call_original
 
-        redis_store_class.with { |redis| redis.call(:mget, 'foo', 'bar') }
+        redis_client.call(:mget, 'foo', 'bar')
       end
 
       it 'does not count allowed cross-slot requests' do
@@ -122,7 +107,7 @@
         expect(instrumentation_class).to receive(:increment_allowed_cross_slot_request_count).and_call_original
 
         Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do
-          redis_store_class.with { |redis| redis.call(:mget, 'foo', 'bar') }
+          redis_client.call(:mget, 'foo', 'bar')
         end
       end
 
@@ -131,7 +116,7 @@
         expect(instrumentation_class).not_to receive(:increment_allowed_cross_slot_request_count).and_call_original
 
         Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do
-          redis_store_class.with { |redis| redis.call(:get, 'bar') }
+          redis_client.call(:mget, 'bar')
         end
       end
 
@@ -139,7 +124,7 @@
         expect(instrumentation_class).not_to receive(:increment_cross_slot_request_count).and_call_original
         expect(instrumentation_class).not_to receive(:increment_allowed_cross_slot_request_count).and_call_original
 
-        redis_store_class.with { |redis| redis.call(:mget, '{foo}bar', '{foo}baz') }
+        redis_client.call(:mget, '{foo}bar', '{foo}baz')
       end
     end
 
@@ -150,7 +135,7 @@
 
       it 'still runs cross-slot validation' do
         expect do
-          redis_store_class.with { |redis| redis.mget('foo', 'bar') }
+          redis_client.call('mget', 'foo', 'bar')
         end.to raise_error(instance_of(Gitlab::Instrumentation::RedisClusterValidator::CrossSlotError))
       end
     end
@@ -172,7 +157,7 @@
           expect(instrumentation_class).to receive(:instance_observe_duration).with(a_value > 0)
             .and_call_original
 
-          redis_store_class.with { |redis| redis.call(*command) }
+          redis_client.call(*command)
         end
       end
 
@@ -181,21 +166,17 @@
           expect(instrumentation_class).to receive(:instance_observe_duration).twice.with(a_value > 0)
             .and_call_original
 
-          redis_store_class.with do |redis|
-            redis.pipelined do |pipeline|
-              pipeline.call(:get, '{foobar}buz')
-              pipeline.call(:get, '{foobar}baz')
-            end
+          redis_client.pipelined do |pipeline|
+            pipeline.call(:get, '{foobar}buz')
+            pipeline.call(:get, '{foobar}baz')
           end
         end
 
         it 'raises error when keys are not from the same slot' do
           expect do
-            redis_store_class.with do |redis|
-              redis.pipelined do |pipeline|
-                pipeline.call(:get, 'foo')
-                pipeline.call(:get, 'bar')
-              end
+            redis_client.pipelined do |pipeline|
+              pipeline.call(:get, 'foo')
+              pipeline.call(:get, 'bar')
             end
           end.to raise_error(instance_of(Gitlab::Instrumentation::RedisClusterValidator::CrossSlotError))
         end
@@ -219,11 +200,11 @@
 
       with_them do
         it 'skips requests we do not want in the apdex' do
-          setup.each { |cmd| redis_store_class.with { |redis| redis.call(*cmd) } }
+          setup.each { |cmd| redis_client.call(*cmd) }
 
           expect(instrumentation_class).not_to receive(:instance_observe_duration)
 
-          redis_store_class.with { |redis| redis.call(*command) }
+          redis_client.call(*command)
         end
       end
 
@@ -231,12 +212,10 @@
         it 'skips requests that have blocking commands' do
           expect(instrumentation_class).not_to receive(:instance_observe_duration)
 
-          redis_store_class.with do |redis|
-            redis.pipelined do |pipeline|
-              pipeline.call(:get, '{foobar}buz')
-              pipeline.call(:rpush, '{foobar}baz', 1)
-              pipeline.call(:brpop, '{foobar}baz', 0)
-            end
+          redis_client.pipelined do |pipeline|
+            pipeline.call(:get, '{foobar}buz')
+            pipeline.call(:rpush, '{foobar}baz', 1)
+            pipeline.call(:brpop, '{foobar}baz', 0)
           end
         end
       end
diff --git a/spec/lib/gitlab/instrumentation/redis_interceptor_spec.rb b/spec/lib/gitlab/instrumentation/redis_interceptor_spec.rb
new file mode 100644
index 0000000000000..2a160a9d31662
--- /dev/null
+++ b/spec/lib/gitlab/instrumentation/redis_interceptor_spec.rb
@@ -0,0 +1,261 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require 'rspec-parameterized'
+require 'support/helpers/rails_helpers'
+
+RSpec.describe Gitlab::Instrumentation::RedisInterceptor, :request_store, feature_category: :scalability do
+  using RSpec::Parameterized::TableSyntax
+  include RedisHelpers
+
+  let_it_be(:redis_store_class) { define_helper_redis_store_class }
+
+  before do
+    redis_store_class.with(&:flushdb)
+  end
+
+  describe 'read and write' do
+    where(:setup, :command, :expect_write, :expect_read) do
+      # The response is 'OK', the request size is the combined size of array
+      # elements. Exercise counting of a status reply.
+      [] | [:set, 'foo', 'bar'] | 3 + 3 + 3 | 2
+
+      # The response is 1001, so 4 bytes. Exercise counting an integer reply.
+      [[:set, 'foobar', 1000]] | [:incr, 'foobar'] | 4 + 6 | 4
+
+      # Exercise counting empty multi bulk reply
+      [] | [:hgetall, 'foobar'] | 7 + 6 | 0
+
+      # Hgetall response length is combined length of keys and values in the
+      # hash. Exercises counting of a multi bulk reply
+      [[:hset, 'myhash', 'field', 'hello world']] | [:hgetall, 'myhash'] | 7 + 6 | 5 + 11
+
+      # Exercise counting of a bulk reply
+      [[:set, 'foo', 'bar' * 100]] | [:get, 'foo'] | 3 + 3 | 3 * 100
+
+      # Nested array response: [['foo', 0], ['bar', 1]]
+      [[:zadd, 'myset', 0, 'foo'], [:zadd, 'myset', 1, 'bar']] | [:zrange, 'myset', 0, -1, 'withscores'] | 6 + 5 + 1 + 2 + 10 | 3 + 1 + 3 + 1
+    end
+
+    with_them do
+      it 'counts bytes read and written' do
+        redis_store_class.with do |redis|
+          setup.each { |cmd| redis.call(cmd) }
+          RequestStore.clear!
+          redis.call(command)
+        end
+
+        expect(Gitlab::Instrumentation::Redis.read_bytes).to eq(expect_read)
+        expect(Gitlab::Instrumentation::Redis.write_bytes).to eq(expect_write)
+      end
+    end
+  end
+
+  describe 'counting' do
+    let(:instrumentation_class) { redis_store_class.instrumentation_class }
+
+    it 'counts successful requests' do
+      expect(instrumentation_class).to receive(:instance_count_request).with(1).and_call_original
+
+      redis_store_class.with { |redis| redis.call(:get, 'foobar') }
+    end
+
+    it 'counts successful pipelined requests' do
+      expect(instrumentation_class).to receive(:instance_count_request).with(2).and_call_original
+      expect(instrumentation_class).to receive(:instance_count_pipelined_request).with(2).and_call_original
+
+      redis_store_class.with do |redis|
+        redis.pipelined do |pipeline|
+          pipeline.call(:get, '{foobar}buz')
+          pipeline.call(:get, '{foobar}baz')
+        end
+      end
+    end
+
+    context 'when encountering connection exceptions within process' do
+      before do
+        redis_store_class.with do |redis|
+          allow(redis._client).to receive(:write).and_call_original
+        end
+      end
+
+      it 'counts connection exceptions' do
+        redis_store_class.with do |redis|
+          expect(redis._client).to receive(:write).with([:get, 'foobar']).and_raise(::Redis::ConnectionError)
+        end
+
+        expect(instrumentation_class).to receive(:instance_count_connection_exception)
+                                             .with(instance_of(Redis::ConnectionError)).and_call_original
+
+        redis_store_class.with { |redis| redis.call(:get, 'foobar') }
+      end
+    end
+
+    context 'when encountering exceptions' do
+      where(:case_name, :exception, :exception_counter) do
+        'generic exception' | Redis::CommandError                                 | :instance_count_exception
+        'moved redirection' | Redis::CommandError.new("MOVED 123 127.0.0.1:6380") | :instance_count_cluster_redirection
+        'ask redirection'   | Redis::CommandError.new("ASK 123 127.0.0.1:6380")   | :instance_count_cluster_redirection
+      end
+
+      with_them do
+        before do
+          redis_store_class.with do |redis|
+            # We need to go 1 layer deeper to stub _client as we monkey-patch Redis::Client
+            # with the interceptor. Stubbing `redis` will skip the instrumentation_class.
+            allow(redis._client).to receive(:process).and_raise(exception)
+          end
+        end
+
+        it 'counts exception' do
+          expect(instrumentation_class).to receive(exception_counter)
+                                             .with(instance_of(Redis::CommandError)).and_call_original
+          expect(instrumentation_class).to receive(:log_exception)
+                                             .with(instance_of(Redis::CommandError)).and_call_original
+          expect(instrumentation_class).to receive(:instance_count_request).and_call_original
+
+          expect do
+            redis_store_class.with { |redis| redis.call(:auth, 'foo', 'bar') }
+          end.to raise_exception(Redis::CommandError)
+
+          expect(Thread.current[:redis_client_error_count]).to eq(0)
+        end
+      end
+    end
+
+    context 'in production environment' do
+      before do
+        stub_rails_env('production') # to avoid raising CrossSlotError
+      end
+
+      it 'counts disallowed cross-slot requests' do
+        expect(instrumentation_class).to receive(:increment_cross_slot_request_count).and_call_original
+        expect(instrumentation_class).not_to receive(:increment_allowed_cross_slot_request_count).and_call_original
+
+        redis_store_class.with { |redis| redis.call(:mget, 'foo', 'bar') }
+      end
+
+      it 'does not count allowed cross-slot requests' do
+        expect(instrumentation_class).not_to receive(:increment_cross_slot_request_count).and_call_original
+        expect(instrumentation_class).to receive(:increment_allowed_cross_slot_request_count).and_call_original
+
+        Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do
+          redis_store_class.with { |redis| redis.call(:mget, 'foo', 'bar') }
+        end
+      end
+
+      it 'does not count allowed non-cross-slot requests' do
+        expect(instrumentation_class).not_to receive(:increment_cross_slot_request_count).and_call_original
+        expect(instrumentation_class).not_to receive(:increment_allowed_cross_slot_request_count).and_call_original
+
+        Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do
+          redis_store_class.with { |redis| redis.call(:get, 'bar') }
+        end
+      end
+
+      it 'skips count for non-cross-slot requests' do
+        expect(instrumentation_class).not_to receive(:increment_cross_slot_request_count).and_call_original
+        expect(instrumentation_class).not_to receive(:increment_allowed_cross_slot_request_count).and_call_original
+
+        redis_store_class.with { |redis| redis.call(:mget, '{foo}bar', '{foo}baz') }
+      end
+    end
+
+    context 'without active RequestStore' do
+      before do
+        ::RequestStore.end!
+      end
+
+      it 'still runs cross-slot validation' do
+        expect do
+          redis_store_class.with { |redis| redis.mget('foo', 'bar') }
+        end.to raise_error(instance_of(Gitlab::Instrumentation::RedisClusterValidator::CrossSlotError))
+      end
+    end
+  end
+
+  describe 'latency' do
+    let(:instrumentation_class) { redis_store_class.instrumentation_class }
+
+    describe 'commands in the apdex' do
+      where(:command) do
+        [
+          [[:get, 'foobar']],
+          [%w[GET foobar]]
+        ]
+      end
+
+      with_them do
+        it 'measures requests we want in the apdex' do
+          expect(instrumentation_class).to receive(:instance_observe_duration).with(a_value > 0)
+            .and_call_original
+
+          redis_store_class.with { |redis| redis.call(*command) }
+        end
+      end
+
+      context 'with pipelined commands' do
+        it 'measures requests that do not have blocking commands' do
+          expect(instrumentation_class).to receive(:instance_observe_duration).twice.with(a_value > 0)
+            .and_call_original
+
+          redis_store_class.with do |redis|
+            redis.pipelined do |pipeline|
+              pipeline.call(:get, '{foobar}buz')
+              pipeline.call(:get, '{foobar}baz')
+            end
+          end
+        end
+
+        it 'raises error when keys are not from the same slot' do
+          expect do
+            redis_store_class.with do |redis|
+              redis.pipelined do |pipeline|
+                pipeline.call(:get, 'foo')
+                pipeline.call(:get, 'bar')
+              end
+            end
+          end.to raise_error(instance_of(Gitlab::Instrumentation::RedisClusterValidator::CrossSlotError))
+        end
+      end
+    end
+
+    describe 'commands not in the apdex' do
+      where(:setup, :command) do
+        [['rpush', 'foobar', 1]] | ['brpop', 'foobar', 0]
+        [['rpush', 'foobar', 1]] | ['blpop', 'foobar', 0]
+        [['rpush', '{abc}foobar', 1]] | ['brpoplpush', '{abc}foobar', '{abc}bazqux', 0]
+        [['rpush', '{abc}foobar', 1]] | ['brpoplpush', '{abc}foobar', '{abc}bazqux', 0]
+        [['zadd', 'foobar', 1, 'a']] | ['bzpopmin', 'foobar', 0]
+        [['zadd', 'foobar', 1, 'a']] | ['bzpopmax', 'foobar', 0]
+        [['xadd', 'mystream', 1, 'myfield', 'mydata']] | ['xread', 'block', 1, 'streams', 'mystream', '0-0']
+        [['xadd', 'foobar', 1, 'myfield', 'mydata'], ['xgroup', 'create', 'foobar', 'mygroup', 0]] | ['xreadgroup', 'group', 'mygroup', 'myconsumer', 'block', 1, 'streams', 'foobar', '0-0']
+        [] | ['command']
+      end
+
+      with_them do
+        it 'skips requests we do not want in the apdex' do
+          redis_store_class.with { |redis| setup.each { |cmd| redis.call(*cmd) } }
+
+          expect(instrumentation_class).not_to receive(:instance_observe_duration)
+
+          redis_store_class.with { |redis| redis.call(*command) }
+        end
+      end
+
+      context 'with pipelined commands' do
+        it 'skips requests that have blocking commands' do
+          expect(instrumentation_class).not_to receive(:instance_observe_duration)
+
+          redis_store_class.with do |redis|
+            redis.pipelined do |pipeline|
+              pipeline.call(:get, '{foobar}buz')
+              pipeline.call(:rpush, '{foobar}baz', 1)
+              pipeline.call(:brpop, '{foobar}baz', 0)
+            end
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/spec/lib/gitlab/patch/node_loader_spec.rb b/spec/lib/gitlab/patch/node_loader_spec.rb
new file mode 100644
index 0000000000000..000083fc6d06b
--- /dev/null
+++ b/spec/lib/gitlab/patch/node_loader_spec.rb
@@ -0,0 +1,80 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Patch::NodeLoader, feature_category: :redis do
+  using RSpec::Parameterized::TableSyntax
+
+  describe '#fetch_node_info' do
+    let(:redis) { double(:redis) } # rubocop:disable RSpec/VerifiedDoubles
+
+    # rubocop:disable Naming/InclusiveLanguage
+    where(:case_name, :args, :value) do
+      [
+        [
+          'when only ip address is present',
+          "07c37df 127.0.0.1:30004@31004 slave e7d1eec 0 1426238317239 4 connected
+67ed2db 127.0.0.1:30002@31002 master - 0 1426238316232 2 connected 5461-10922
+292f8b3 127.0.0.1:30003@31003 master - 0 1426238318243 3 connected 10923-16383
+6ec2392 127.0.0.1:30005@31005 slave 67ed2db 0 1426238316232 5 connected
+824fe11 127.0.0.1:30006@31006 slave 292f8b3 0 1426238317741 6 connected
+e7d1eec 127.0.0.1:30001@31001 myself,master - 0 0 1 connected 0-5460",
+          {
+            '127.0.0.1:30004' => 'slave', '127.0.0.1:30002' => 'master', '127.0.0.1:30003' => 'master',
+            '127.0.0.1:30005' => 'slave', '127.0.0.1:30006' => 'slave', '127.0.0.1:30001' => 'master'
+          }
+        ],
+        [
+          'when hostname is present',
+          "07c37df 127.0.0.1:30004@31004,host1 slave e7d1eec 0 1426238317239 4 connected
+67ed2db 127.0.0.1:30002@31002,host2 master - 0 1426238316232 2 connected 5461-10922
+292f8b3 127.0.0.1:30003@31003,host3 master - 0 1426238318243 3 connected 10923-16383
+6ec2392 127.0.0.1:30005@31005,host4 slave 67ed2db 0 1426238316232 5 connected
+824fe11 127.0.0.1:30006@31006,host5 slave 292f8b3 0 1426238317741 6 connected
+e7d1eec 127.0.0.1:30001@31001,host6 myself,master - 0 0 1 connected 0-5460",
+          {
+            'host1:30004' => 'slave', 'host2:30002' => 'master', 'host3:30003' => 'master',
+            'host4:30005' => 'slave', 'host5:30006' => 'slave', 'host6:30001' => 'master'
+          }
+        ],
+        [
+          'when auxiliary fields are present',
+          "07c37df 127.0.0.1:30004@31004,,shard-id=69bc slave e7d1eec 0 1426238317239 4 connected
+67ed2db 127.0.0.1:30002@31002,,shard-id=114f master - 0 1426238316232 2 connected 5461-10922
+292f8b3 127.0.0.1:30003@31003,,shard-id=fdb3 master - 0 1426238318243 3 connected 10923-16383
+6ec2392 127.0.0.1:30005@31005,,shard-id=114f slave 67ed2db 0 1426238316232 5 connected
+824fe11 127.0.0.1:30006@31006,,shard-id=fdb3 slave 292f8b3 0 1426238317741 6 connected
+e7d1eec 127.0.0.1:30001@31001,,shard-id=69bc myself,master - 0 0 1 connected 0-5460",
+          {
+            '127.0.0.1:30004' => 'slave', '127.0.0.1:30002' => 'master', '127.0.0.1:30003' => 'master',
+            '127.0.0.1:30005' => 'slave', '127.0.0.1:30006' => 'slave', '127.0.0.1:30001' => 'master'
+          }
+        ],
+        [
+          'when hostname and auxiliary fields are present',
+          "07c37df 127.0.0.1:30004@31004,host1,shard-id=69bc slave e7d1eec 0 1426238317239 4 connected
+67ed2db 127.0.0.1:30002@31002,host2,shard-id=114f master - 0 1426238316232 2 connected 5461-10922
+292f8b3 127.0.0.1:30003@31003,host3,shard-id=fdb3 master - 0 1426238318243 3 connected 10923-16383
+6ec2392 127.0.0.1:30005@31005,host4,shard-id=114f slave 67ed2db 0 1426238316232 5 connected
+824fe11 127.0.0.1:30006@31006,host5,shard-id=fdb3 slave 292f8b3 0 1426238317741 6 connected
+e7d1eec 127.0.0.1:30001@31001,host6,shard-id=69bc myself,master - 0 0 1 connected 0-5460",
+          {
+            'host1:30004' => 'slave', 'host2:30002' => 'master', 'host3:30003' => 'master',
+            'host4:30005' => 'slave', 'host5:30006' => 'slave', 'host6:30001' => 'master'
+          }
+        ]
+      ]
+    end
+    # rubocop:enable Naming/InclusiveLanguage
+
+    with_them do
+      before do
+        allow(redis).to receive(:call).with([:cluster, :nodes]).and_return(args)
+      end
+
+      it do
+        expect(Redis::Cluster::NodeLoader.load_flags([redis])).to eq(value)
+      end
+    end
+  end
+end
diff --git a/spec/lib/gitlab/patch/redis_cache_store_spec.rb b/spec/lib/gitlab/patch/redis_cache_store_spec.rb
index ae9c31e0c513a..21c256fdbbee3 100644
--- a/spec/lib/gitlab/patch/redis_cache_store_spec.rb
+++ b/spec/lib/gitlab/patch/redis_cache_store_spec.rb
@@ -13,8 +13,6 @@
     cache.write('{user1}:x', 1)
     cache.write('{user1}:y', 2)
     cache.write('{user1}:z', 3)
-
-    cache.instance_variable_set(:@pipeline_batch_size, nil)
   end
 
   describe '#read_multi_mget' do
@@ -36,7 +34,7 @@
       end
 
       context 'when reading large amount of keys' do
-        let(:input_size) { 2100 }
+        let(:input_size) { 2000 }
         let(:chunk_size) { 1000 }
 
         shared_examples 'read large amount of keys' do
@@ -47,11 +45,10 @@
                 ::Gitlab::Redis::ClusterUtil.cluster?(redis.default_store)
 
               if normal_cluster || multistore_cluster
-                times = (input_size.to_f / chunk_size).ceil
-                expect(redis).to receive(:pipelined).exactly(times).times.and_call_original
-
-                expect_next_instances_of(::Redis::PipelinedConnection, times) do |p|
-                  expect(p).to receive(:get).at_most(chunk_size).times
+                expect_next_instances_of(Gitlab::Redis::CrossSlot::Pipeline, 2) do |pipeline|
+                  obj = instance_double(::Redis)
+                  expect(pipeline).to receive(:pipelined).and_yield(obj)
+                  expect(obj).to receive(:get).exactly(chunk_size).times
                 end
               else
                 expect(redis).to receive(:mget).and_call_original
diff --git a/spec/lib/gitlab/patch/redis_client_spec.rb b/spec/lib/gitlab/patch/redis_client_spec.rb
index 3e2d46dd19c8c..af094e9e0d251 100644
--- a/spec/lib/gitlab/patch/redis_client_spec.rb
+++ b/spec/lib/gitlab/patch/redis_client_spec.rb
@@ -6,7 +6,7 @@
   include RedisHelpers
 
   let_it_be(:redis_store_class) { define_helper_redis_store_class }
-  let_it_be(:redis_client) { RedisClient.new(redis_store_class.params) }
+  let_it_be(:redis_client) { RedisClient.new(redis_store_class.redis_client_params) }
 
   before do
     Thread.current[:redis_client_error_count] = 1
diff --git a/spec/lib/gitlab/patch/redis_store_factory_spec.rb b/spec/lib/gitlab/patch/redis_store_factory_spec.rb
deleted file mode 100644
index be37a044ac154..0000000000000
--- a/spec/lib/gitlab/patch/redis_store_factory_spec.rb
+++ /dev/null
@@ -1,29 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::Patch::RedisStoreFactory, feature_category: :redis do
-  describe '#create' do
-    let(:params) { { host: 'localhost' } }
-
-    subject(:factory_create) { ::Redis::Store::Factory.create(params) } # rubocop:disable Rails/SaveBang -- redis-store does not implement create!
-
-    context 'when using standalone Redis' do
-      it 'does not create ClusterStore' do
-        expect(Gitlab::Redis::ClusterStore).not_to receive(:new)
-
-        factory_create
-      end
-    end
-
-    context 'when using a Redis Cluster' do
-      let(:params) { { nodes: ["redis://localhost:6001", "redis://localhost:6002"] } }
-
-      it 'creates a ClusterStore' do
-        expect(Gitlab::Redis::ClusterStore).to receive(:new).with(params.merge({ raw: false }))
-
-        factory_create
-      end
-    end
-  end
-end
diff --git a/spec/lib/gitlab/rack_attack/store_spec.rb b/spec/lib/gitlab/rack_attack/store_spec.rb
index efe6f9382f954..19b3f239d915f 100644
--- a/spec/lib/gitlab/rack_attack/store_spec.rb
+++ b/spec/lib/gitlab/rack_attack/store_spec.rb
@@ -102,7 +102,7 @@ def with_redis(&block)
       before do
         broken_redis = Redis.new(
           url: 'redis://127.0.0.0:0',
-          custom: { instrumentation_class: Gitlab::Redis::RateLimiting.instrumentation_class }
+          instrumentation_class: Gitlab::Redis::RateLimiting.instrumentation_class
         )
         allow(Gitlab::Redis::RateLimiting).to receive(:with).and_yield(broken_redis)
       end
diff --git a/spec/lib/gitlab/redis/cluster_store_spec.rb b/spec/lib/gitlab/redis/cluster_store_spec.rb
deleted file mode 100644
index aaeee6156a0d8..0000000000000
--- a/spec/lib/gitlab/redis/cluster_store_spec.rb
+++ /dev/null
@@ -1,116 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-# This spec only runs if a Redis Cluster is configured for Gitlab::Redis::Cache.
-# ::Redis::Cluster fetches the cluster details from the server on `initialize` and will raise
-# an error if the cluster is not found.
-#
-# An example would be the following in config/redis.yml assuming gdk is set up with redis-cluster.
-# test:
-#   cache
-#     cluster:
-#       - "redis://127.0.0.1:6003"
-#       - "redis://127.0.0.1:6004"
-#       - "redis://127.0.0.1:6005"
-RSpec.describe Gitlab::Redis::ClusterStore, :clean_gitlab_redis_cache,
-  feature_category: :redis, if: ::Gitlab::Redis::Cache.params[:nodes] do
-  let(:params) { ::Gitlab::Redis::Cache.params }
-
-  subject(:store) { ::Redis::Store::Factory.create(params) } # rubocop:disable Rails/SaveBang -- not a rails method
-
-  describe '.new' do
-    it 'initialises a cluster store' do
-      expect(store).to be_instance_of(::Gitlab::Redis::ClusterStore)
-    end
-
-    it 'extends Serialization by default' do
-      expect(store.is_a?(::Redis::Store::Serialization)).to eq(true)
-    end
-
-    it 'sets a default serializer when left empty' do
-      expect(store.instance_variable_get(:@serializer)).to eq(Marshal)
-    end
-
-    context 'when serializer field is defined' do
-      let(:params) { ::Gitlab::Redis::Cache.params.merge(serializer: Class) }
-
-      it 'sets serializer according to the options' do
-        expect(store.instance_variable_get(:@serializer)).to eq(Class)
-      end
-    end
-
-    context 'when marshalling field is defined' do
-      let(:params) { ::Gitlab::Redis::Cache.params.merge(marshalling: true, serializer: Class) }
-
-      it 'overrides serializer with Marshal' do
-        expect(store.instance_variable_get(:@serializer)).to eq(Marshal)
-      end
-    end
-
-    context 'when marshalling field is false' do
-      let(:params) { ::Gitlab::Redis::Cache.params.merge(marshalling: false, serializer: Class) }
-
-      it 'overrides serializer with Marshal' do
-        expect(store.instance_variable_get(:@serializer)).to eq(nil)
-      end
-    end
-
-    context 'when namespace is defined' do
-      let(:params) { ::Gitlab::Redis::Cache.params.merge(namespace: 'testing') }
-
-      it 'extends namespace' do
-        expect(store.is_a?(::Redis::Store::Namespace)).to eq(true)
-      end
-
-      it 'write keys with namespace' do
-        store.set('testkey', 1)
-
-        ::Gitlab::Redis::Cache.with do |conn|
-          expect(conn.exists('testing:testkey')).to eq(1)
-        end
-      end
-    end
-  end
-
-  describe '#set' do
-    context 'when ttl is added' do
-      it 'writes the key and sets a ttl' do
-        expect(store.set('test', 1, expire_after: 100)).to eq('OK')
-
-        expect(store.ttl('test')).to be > 95
-        expect(store.get('test')).to eq(1)
-      end
-    end
-
-    context 'when there is no ttl' do
-      it 'sets the key' do
-        expect(store.set('test', 1)).to eq('OK')
-
-        expect(store.get('test')).to eq(1)
-        expect(store.ttl('test')).to eq(-1)
-      end
-    end
-  end
-
-  describe '#setnx' do
-    context 'when ttl is added' do
-      it 'writes the key if not exists and sets a ttl' do
-        expect(store.setnx('test', 1, expire_after: 100)).to eq([true, true])
-        expect(store.ttl('test')).to be > 95
-        expect(store.get('test')).to eq(1)
-        expect(store.setnx('test', 1, expire_after: 100)).to eq([false, true])
-      end
-    end
-
-    context 'when there is no ttl' do
-      it 'writes the key if not exists' do
-        expect(store.setnx('test', 1)).to eq(true)
-        expect(store.setnx('test', 1)).to eq(false)
-
-        expect(store.get('test')).to eq(1)
-        expect(store.ttl('test')).to eq(-1)
-      end
-    end
-  end
-end
diff --git a/spec/lib/gitlab/redis/cluster_util_spec.rb b/spec/lib/gitlab/redis/cluster_util_spec.rb
index 1cd6a450584bb..a517545014508 100644
--- a/spec/lib/gitlab/redis/cluster_util_spec.rb
+++ b/spec/lib/gitlab/redis/cluster_util_spec.rb
@@ -5,14 +5,10 @@
 RSpec.describe Gitlab::Redis::ClusterUtil, feature_category: :scalability do
   using RSpec::Parameterized::TableSyntax
 
-  let(:router_stub) { instance_double(::RedisClient::Cluster::Router) }
-
-  before do
-    allow(::RedisClient::Cluster::Router).to receive(:new).and_return(router_stub)
-  end
-
   describe '.cluster?' do
     context 'when MultiStore' do
+      let(:redis_cluster) { instance_double(::Redis::Cluster) }
+
       where(:pri_store, :sec_store, :expected_val) do
         :cluster | :cluster | true
         :cluster | :single  | true
@@ -21,7 +17,10 @@
       end
 
       before do
-        allow(router_stub).to receive(:node_keys).and_return([])
+        # stub all initialiser steps in Redis::Cluster.new to avoid connecting to a Redis Cluster node
+        allow(::Redis::Cluster).to receive(:new).and_return(redis_cluster)
+        allow(redis_cluster).to receive(:is_a?).with(::Redis::Cluster).and_return(true)
+        allow(redis_cluster).to receive(:id).and_return(1)
 
         allow(Gitlab::Redis::MultiStore).to receive(:same_redis_store?).and_return(false)
         skip_default_enabled_yaml_check
@@ -29,8 +28,8 @@
 
       with_them do
         it 'returns expected value' do
-          primary_redis = pri_store == :cluster ? Redis::Cluster.new(nodes: ['redis://localhost:6000']) : Redis.new
-          secondary_redis = sec_store == :cluster ? Redis::Cluster.new(nodes: ['redis://localhost:6000']) : Redis.new
+          primary_redis = pri_store == :cluster ? ::Redis.new(cluster: ['redis://localhost:6000']) : ::Redis.new
+          secondary_redis = sec_store == :cluster ? ::Redis.new(cluster: ['redis://localhost:6000']) : ::Redis.new
           primary_pool = ConnectionPool.new { primary_redis }
           secondary_pool = ConnectionPool.new { secondary_redis }
           multistore = Gitlab::Redis::MultiStore.new(primary_pool, secondary_pool, 'teststore')
@@ -49,8 +48,16 @@
     end
 
     context 'when is Redis::Cluster' do
+      let(:redis_cluster) { instance_double(::Redis::Cluster) }
+
+      before do
+        # stub all initialiser steps in Redis::Cluster.new to avoid connecting to a Redis Cluster node
+        allow(::Redis::Cluster).to receive(:new).and_return(redis_cluster)
+        allow(redis_cluster).to receive(:is_a?).with(::Redis::Cluster).and_return(true)
+      end
+
       it 'returns true' do
-        expect(described_class.cluster?(Redis::Cluster.new(nodes: ['redis://localhost:6000']))).to be_truthy
+        expect(described_class.cluster?(::Redis.new(cluster: ['redis://localhost:6000']))).to be_truthy
       end
     end
   end
diff --git a/spec/lib/gitlab/redis/command_builder_spec.rb b/spec/lib/gitlab/redis/command_builder_spec.rb
deleted file mode 100644
index 7ebb0228d2d7a..0000000000000
--- a/spec/lib/gitlab/redis/command_builder_spec.rb
+++ /dev/null
@@ -1,57 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-# references specs in https://github.com/redis-rb/redis-client/blob/master/test/redis_client/command_builder_test.rb
-# we add `handles nil arguments` to test our own added logic
-RSpec.describe Gitlab::Redis::CommandBuilder, feature_category: :redis do
-  describe '.generate' do
-    def call(*args, **kwargs)
-      described_class.generate(args, kwargs)
-    end
-
-    it 'handles nil arguments' do
-      expect(call("a", nil)).to eq(["a", ""])
-    end
-
-    it 'handles positional arguments' do
-      expect(call("a", "b", "c")).to eq(%w[a b c])
-    end
-
-    it 'handles arrays' do
-      expect(call("a", %w[b c])).to eq(%w[a b c])
-    end
-
-    it 'handles hashes' do
-      expect(call("a", { "b" => "c" })).to eq(%w[a b c])
-    end
-
-    it 'handles symbols' do
-      expect(call(:a, { b: :c }, :d)).to eq(%w[a b c d])
-    end
-
-    it 'handles numerics' do
-      expect(call(1, 2.3)).to eq(["1", "2.3"])
-    end
-
-    it 'handles kwargs booleans' do
-      expect(call(ttl: nil, ex: false, withscores: true)).to eq(["withscores"])
-    end
-
-    it 'handles kwargs values' do
-      expect(call(ttl: 42)).to eq(%w[ttl 42])
-    end
-
-    it 'handles nil kwargs' do
-      expect(call(%i[a b c])).to eq(%w[a b c])
-    end
-
-    it 'raises error on unsupported types' do
-      expect { call(hash: {}) }.to raise_error(TypeError)
-    end
-
-    it 'raises error on empty commands' do
-      expect { call }.to raise_error(ArgumentError)
-    end
-  end
-end
diff --git a/spec/lib/gitlab/redis/cross_slot_spec.rb b/spec/lib/gitlab/redis/cross_slot_spec.rb
new file mode 100644
index 0000000000000..4e9830f4110a2
--- /dev/null
+++ b/spec/lib/gitlab/redis/cross_slot_spec.rb
@@ -0,0 +1,134 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Redis::CrossSlot, feature_category: :redis do
+  include RedisHelpers
+
+  let_it_be(:redis_store_class) { define_helper_redis_store_class }
+
+  before do
+    redis_store_class.with(&:flushdb)
+  end
+
+  describe '.pipelined' do
+    context 'when using redis client' do
+      before do
+        redis_store_class.with { |redis| redis.set('a', 1) }
+      end
+
+      it 'performs redis-rb pipelined' do
+        expect(Gitlab::Redis::CrossSlot::Router).not_to receive(:new)
+
+        expect(
+          Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do
+            redis_store_class.with do |redis|
+              described_class::Pipeline.new(redis).pipelined do |p|
+                p.get('a')
+                p.set('b', 1)
+              end
+            end
+          end
+        ).to eq(%w[1 OK])
+      end
+    end
+
+    context 'when using with MultiStore' do
+      let_it_be(:primary_db) { 1 }
+      let_it_be(:secondary_db) { 2 }
+      let_it_be(:primary_store) { create_redis_store(redis_store_class.params, db: primary_db, serializer: nil) }
+      let_it_be(:secondary_store) { create_redis_store(redis_store_class.params, db: secondary_db, serializer: nil) }
+      let_it_be(:primary_pool) { ConnectionPool.new { primary_store } }
+      let_it_be(:secondary_pool) { ConnectionPool.new { secondary_store } }
+      let_it_be(:multistore) { Gitlab::Redis::MultiStore.new(primary_pool, secondary_pool, 'testing') }
+
+      before do
+        primary_store.set('a', 1)
+        secondary_store.set('a', 1)
+        skip_default_enabled_yaml_check
+      end
+
+      it 'performs multistore pipelined' do
+        expect(Gitlab::Redis::CrossSlot::Router).not_to receive(:new)
+
+        expect(
+          Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do
+            multistore.with_borrowed_connection do
+              described_class::Pipeline.new(multistore).pipelined do |p|
+                p.get('a')
+                p.set('b', 1)
+              end
+            end
+          end
+        ).to eq(%w[1 OK])
+      end
+    end
+
+    context 'when using Redis::Cluster' do
+      # Only stub redis client internals since the CI pipeline does not run a Redis Cluster
+      let(:redis) { double(:redis) } # rubocop:disable RSpec/VerifiedDoubles
+      let(:client) { double(:client) } # rubocop:disable RSpec/VerifiedDoubles
+      let(:pipeline) { double(:pipeline) } # rubocop:disable RSpec/VerifiedDoubles
+
+      let(:arguments) { %w[a b c d] }
+
+      subject do
+        described_class::Pipeline.new(redis).pipelined do |p|
+          arguments.each { |key| p.get(key) }
+        end
+      end
+
+      before do
+        allow(redis).to receive(:_client).and_return(client)
+        allow(redis).to receive(:pipelined).and_yield(pipeline)
+        allow(client).to receive(:instance_of?).with(::Redis::Cluster).and_return(true)
+      end
+
+      it 'fan-out and fan-in commands to separate shards' do
+        # simulate fan-out to 3 shards with random order
+        expect(client).to receive(:_find_node_key).exactly(4).times.and_return(3, 2, 1, 3)
+
+        arguments.each do |key|
+          f = double('future') # rubocop:disable RSpec/VerifiedDoubles
+          expect(pipeline).to receive(:get).with(key).and_return(f)
+          expect(f).to receive(:value).and_return(key)
+        end
+
+        expect(subject).to eq(arguments)
+      end
+
+      shared_examples 'fallback on cross-slot' do |redirection|
+        context 'when redis cluster undergoing slot migration' do
+          before do
+            allow(pipeline).to receive(:get).and_raise(::Redis::CommandError.new("#{redirection} 1 127.0.0.1:7001"))
+          end
+
+          it 'logs error and executes sequentially' do
+            expect(client).to receive(:_find_node_key).exactly(4).times.and_return(3, 2, 1, 3)
+            expect(Gitlab::ErrorTracking).to receive(:log_exception).with(an_instance_of(::Redis::CommandError))
+
+            arguments.each do |key|
+              expect(redis).to receive(:get).with(key).and_return(key)
+            end
+
+            subject
+          end
+        end
+      end
+
+      it_behaves_like 'fallback on cross-slot', 'MOVED'
+      it_behaves_like 'fallback on cross-slot', 'ASK'
+
+      context 'when receiving non-MOVED/ASK command errors' do
+        before do
+          allow(pipeline).to receive(:get).and_raise(::Redis::CommandError.new)
+          allow(client).to receive(:_find_node_key).exactly(4).times.and_return(3, 2, 1, 3)
+        end
+
+        it 'raises error' do
+          expect { subject }.to raise_error(::Redis::CommandError)
+        end
+      end
+    end
+  end
+end
diff --git a/spec/lib/gitlab/redis/multi_store_spec.rb b/spec/lib/gitlab/redis/multi_store_spec.rb
index 0d841be4e3c64..348215d553c03 100644
--- a/spec/lib/gitlab/redis/multi_store_spec.rb
+++ b/spec/lib/gitlab/redis/multi_store_spec.rb
@@ -58,7 +58,6 @@
   context 'when primary_store is not a ::Redis instance' do
     before do
       allow(primary_store).to receive(:is_a?).with(::Redis).and_return(false)
-      allow(primary_store).to receive(:is_a?).with(::Redis::Cluster).and_return(false)
     end
 
     it 'fails with exception' do
@@ -70,7 +69,6 @@
   context 'when secondary_store is not a ::Redis instance' do
     before do
       allow(secondary_store).to receive(:is_a?).with(::Redis).and_return(false)
-      allow(secondary_store).to receive(:is_a?).with(::Redis::Cluster).and_return(false)
     end
 
     it 'fails with exception' do
@@ -620,6 +618,35 @@
         end
       end
 
+      context 'when either store is a an instance of ::Redis::Cluster' do
+        let(:pipeline) { double }
+        let(:client) { double }
+
+        before do
+          allow(client).to receive(:instance_of?).with(::Redis::Cluster).and_return(true)
+          allow(pipeline).to receive(:pipelined)
+          multi_store.with_borrowed_connection do
+            allow(multi_store.default_store).to receive(:_client).and_return(client)
+          end
+        end
+
+        it 'calls cross-slot pipeline within multistore' do
+          if name == :pipelined
+            # we intentionally exclude `.and_call_original` since primary_store/secondary_store
+            # may not be running on a proper Redis Cluster.
+            multi_store.with_borrowed_connection do
+              expect(Gitlab::Redis::CrossSlot::Pipeline).to receive(:new)
+                                                              .with(multi_store.default_store)
+                                                              .exactly(:once)
+                                                              .and_return(pipeline)
+              expect(Gitlab::Redis::CrossSlot::Pipeline).not_to receive(:new).with(multi_store.non_default_store)
+            end
+          end
+
+          subject
+        end
+      end
+
       context 'when with_readonly_pipeline is used' do
         it 'calls the default store only' do
           expect(primary_store).to receive(:send).and_call_original
diff --git a/spec/lib/gitlab/redis/sessions_spec.rb b/spec/lib/gitlab/redis/sessions_spec.rb
index e822b7399b741..874822e3e6acc 100644
--- a/spec/lib/gitlab/redis/sessions_spec.rb
+++ b/spec/lib/gitlab/redis/sessions_spec.rb
@@ -8,9 +8,9 @@
   describe '#store' do
     subject(:store) { described_class.store(namespace: described_class::SESSION_NAMESPACE) }
 
-    # Check that Gitlab::Redis::Sessions is configured as RedisStore or ClusterStore
+    # Check that Gitlab::Redis::Sessions is configured as RedisStore.
     it 'instantiates an instance of Redis::Store' do
-      expect([::Redis::Store, ::Gitlab::Redis::ClusterStore].include?(store.class)).to eq(true)
+      expect(store).to be_instance_of(::Redis::Store)
     end
   end
 end
diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb
index a4303a6e0fced..c3e9f5a178e91 100644
--- a/spec/spec_helper.rb
+++ b/spec/spec_helper.rb
@@ -544,18 +544,18 @@ def initialize_from_file_path(path)
 
 Rack::Test::UploadedFile.prepend(TouchRackUploadedFile)
 
-# Inject middleware to enable ActiveSupport::Notifications for Redis commands
+# Monkey-patch to enable ActiveSupport::Notifications for Redis commands
 module RedisCommands
   module Instrumentation
-    def call(command, redis_config)
-      ActiveSupport::Notifications.instrument('redis.process_commands', commands: command) do
-        super(command, redis_config)
+    def process(commands, &block)
+      ActiveSupport::Notifications.instrument('redis.process_commands', commands: commands) do
+        super(commands, &block)
       end
     end
   end
 end
 
-RedisClient.register(RedisCommands::Instrumentation)
+Redis::Client.prepend(RedisCommands::Instrumentation)
 
 module UsersInternalAllowExclusiveLease
   extend ActiveSupport::Concern
diff --git a/spec/support/helpers/dns_helpers.rb b/spec/support/helpers/dns_helpers.rb
index 27310c7e04c96..0250e4326099e 100644
--- a/spec/support/helpers/dns_helpers.rb
+++ b/spec/support/helpers/dns_helpers.rb
@@ -60,8 +60,8 @@ def db_hosts
   def permit_redis!
     # https://github.com/redis-rb/redis-client/blob/v0.11.2/lib/redis_client/ruby_connection.rb#L51 uses Socket.tcp that
     # calls Addrinfo.getaddrinfo internally.
-    hosts = Gitlab::Redis::ALL_CLASSES.flat_map do |redis_instance|
-      redis_instance.params[:host] || redis_instance.params[:nodes]&.map { |n| n[:host] }
+    hosts = Gitlab::Redis::ALL_CLASSES.map do |redis_instance|
+      redis_instance.redis_client_params[:host]
     end.uniq.compact
 
     hosts.each do |host|
diff --git a/spec/support/matchers/exceed_redis_call_limit.rb b/spec/support/matchers/exceed_redis_call_limit.rb
index 2cb92089723a9..2b1e1ebad23a8 100644
--- a/spec/support/matchers/exceed_redis_call_limit.rb
+++ b/spec/support/matchers/exceed_redis_call_limit.rb
@@ -14,7 +14,7 @@ def verify_count(expected, block)
   end
 
   def verify_commands_count(command, expected, block)
-    @actual = build_recorder(block).by_command(command.to_s).count
+    @actual = build_recorder(block).by_command(command).count
 
     @actual > expected
   end
diff --git a/spec/support/rspec_order_todo.yml b/spec/support/rspec_order_todo.yml
index 7e113122eacc6..8791220f11e81 100644
--- a/spec/support/rspec_order_todo.yml
+++ b/spec/support/rspec_order_todo.yml
@@ -6292,6 +6292,7 @@
 - './spec/lib/gitlab/instrumentation/rate_limiting_gates_spec.rb'
 - './spec/lib/gitlab/instrumentation/redis_base_spec.rb'
 - './spec/lib/gitlab/instrumentation/redis_cluster_validator_spec.rb'
+- './spec/lib/gitlab/instrumentation/redis_interceptor_spec.rb'
 - './spec/lib/gitlab/instrumentation/redis_spec.rb'
 - './spec/lib/gitlab/internal_post_receive/response_spec.rb'
 - './spec/lib/gitlab/issuable/clone/attributes_rewriter_spec.rb'
diff --git a/spec/support/shared_examples/lib/gitlab/bitbucket_import/object_import_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/bitbucket_import/object_import_shared_examples.rb
index 55ce520caab5b..3dbe43d822f47 100644
--- a/spec/support/shared_examples/lib/gitlab/bitbucket_import/object_import_shared_examples.rb
+++ b/spec/support/shared_examples/lib/gitlab/bitbucket_import/object_import_shared_examples.rb
@@ -25,7 +25,7 @@
 
         expect(Gitlab::JobWaiter).to receive(:notify).with(waiter_key, anything)
 
-        worker.class.perform_inline(project_id, {}, waiter_key)
+        worker.perform(project_id, {}, waiter_key)
       end
     end
 
@@ -49,7 +49,7 @@
         expect(Gitlab::BitbucketImport::Logger).to receive(:info).twice
         expect_next(worker.importer_class, project, kind_of(Hash)).to receive(:execute)
 
-        worker.class.perform_inline(project_id, {}, waiter_key)
+        worker.perform(project_id, {}, waiter_key)
       end
 
       it_behaves_like 'notifies the waiter'
@@ -62,7 +62,7 @@
         it 'tracks the error' do
           expect(Gitlab::Import::ImportFailureService).to receive(:track).once
 
-          worker.class.perform_inline(project_id, {}, waiter_key)
+          worker.perform(project_id, {}, waiter_key)
         end
       end
 
@@ -74,7 +74,7 @@
         it 'tracks the error and raises the error' do
           expect(Gitlab::Import::ImportFailureService).to receive(:track).once
 
-          expect { worker.class.perform_inline(project_id, {}, waiter_key) }.to raise_error(StandardError)
+          expect { worker.perform(project_id, {}, waiter_key) }.to raise_error(StandardError)
         end
       end
     end
@@ -85,7 +85,7 @@
       it 'does not call the importer' do
         expect_next(worker.importer_class).not_to receive(:execute)
 
-        worker.class.perform_inline(project_id, {}, waiter_key)
+        worker.perform(project_id, {}, waiter_key)
       end
 
       it_behaves_like 'notifies the waiter'
diff --git a/spec/support/shared_examples/lib/gitlab/bitbucket_server_import/object_import_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/bitbucket_server_import/object_import_shared_examples.rb
index 89eec531b87bc..45248f57683b7 100644
--- a/spec/support/shared_examples/lib/gitlab/bitbucket_server_import/object_import_shared_examples.rb
+++ b/spec/support/shared_examples/lib/gitlab/bitbucket_server_import/object_import_shared_examples.rb
@@ -19,10 +19,6 @@
     let(:project_id) { project_id }
     let(:waiter_key) { 'key' }
 
-    before do
-      allow(Gitlab::JobWaiter).to receive(:notify).with(waiter_key, anything, ttl: Gitlab::Import::JOB_WAITER_TTL)
-    end
-
     shared_examples 'notifies the waiter' do
       specify do
         allow_next(worker.importer_class).to receive(:execute)
diff --git a/spec/support/shared_examples/redis/redis_shared_examples.rb b/spec/support/shared_examples/redis/redis_shared_examples.rb
index 2f387528740b6..1c153b7c31b62 100644
--- a/spec/support/shared_examples/redis/redis_shared_examples.rb
+++ b/spec/support/shared_examples/redis/redis_shared_examples.rb
@@ -80,17 +80,18 @@
 
     context 'with new format' do
       it_behaves_like 'redis store' do
-        # use new format host without sentinel details as `.to_s` checks `config` which
-        # tries to resolve master/replica details with an actual sentinel instance.
-        # https://github.com/redis-rb/redis-client/blob/v0.18.0/lib/redis_client/sentinel_config.rb#L128
-        let(:config_file_name) { "spec/fixtures/config/redis_new_format_host_standalone.yml" }
+        let(:config_file_name) { config_new_format_host }
         let(:host) { "development-host:#{redis_port}" }
       end
     end
   end
 
-  describe '.params' do
-    subject { described_class.new(rails_env).params }
+  describe '.redis_client_params' do
+    # .redis_client_params wraps over `.redis_store_options` by modifying its outputs
+    # to be compatible with `RedisClient`. We test for compatibility in this block while
+    # the contents of redis_store_options are tested in the `.params` block.
+
+    subject { described_class.new(rails_env).redis_client_params }
 
     let(:rails_env) { 'development' }
     let(:config_file_name) { config_old_format_socket }
@@ -102,6 +103,56 @@
       end
     end
 
+    context 'when url is host based' do
+      context 'with old format' do
+        let(:config_file_name) { config_old_format_host }
+
+        it 'does not raise ArgumentError for invalid keywords' do
+          expect { RedisClient.config(**subject) }.not_to raise_error
+        end
+
+        it_behaves_like 'instrumentation_class in custom key'
+      end
+
+      context 'with new format' do
+        let(:config_file_name) { config_new_format_host }
+
+        where(:rails_env, :host) do
+          [
+            %w[development development-host],
+            %w[test test-host],
+            %w[production production-host]
+          ]
+        end
+
+        with_them do
+          it 'does not raise ArgumentError for invalid keywords in SentinelConfig' do
+            expect(subject[:name]).to eq(host)
+            expect { RedisClient.sentinel(**subject) }.not_to raise_error
+          end
+
+          it_behaves_like 'instrumentation_class in custom key'
+        end
+      end
+    end
+
+    context 'when url contains unix socket reference' do
+      let(:config_file_name) { config_old_format_socket }
+
+      it 'does not raise ArgumentError for invalid keywords' do
+        expect { RedisClient.config(**subject) }.not_to raise_error
+      end
+
+      it_behaves_like 'instrumentation_class in custom key'
+    end
+  end
+
+  describe '.params' do
+    subject { described_class.new(rails_env).params }
+
+    let(:rails_env) { 'development' }
+    let(:config_file_name) { config_old_format_socket }
+
     it 'withstands mutation' do
       params1 = described_class.params
       params2 = described_class.params
@@ -200,16 +251,10 @@
 
         with_them do
           it 'returns hash with host, port, db, and password' do
-            is_expected.to include(name: host, password: 'mynewpassword', db: redis_database)
+            is_expected.to include(host: host, password: 'mynewpassword', port: redis_port, db: redis_database)
             is_expected.not_to have_key(:url)
           end
-
-          it 'does not raise ArgumentError for invalid keywords in SentinelConfig' do
-            expect { RedisClient.sentinel(**subject) }.not_to raise_error
-          end
         end
-
-        it_behaves_like 'instrumentation_class in custom key'
       end
 
       context 'with redis cluster format' do
@@ -227,19 +272,13 @@
           it 'returns hash with cluster and password' do
             is_expected.to include(
               password: 'myclusterpassword',
-              nodes: [
+              cluster: [
                 { host: "#{host}1", port: redis_port },
                 { host: "#{host}2", port: redis_port }
               ]
             )
             is_expected.not_to have_key(:url)
           end
-
-          it 'does not raise ArgumentError for invalid keywords in ClusterConfig' do
-            expect { RedisClient::ClusterConfig.new(**subject) }.not_to raise_error
-          end
-
-          it_behaves_like 'instrumentation_class in custom key'
         end
       end
     end
diff --git a/spec/support_specs/helpers/redis_commands/recorder_spec.rb b/spec/support_specs/helpers/redis_commands/recorder_spec.rb
index 5c200f15bae3b..ef46db5e29ea7 100644
--- a/spec/support_specs/helpers/redis_commands/recorder_spec.rb
+++ b/spec/support_specs/helpers/redis_commands/recorder_spec.rb
@@ -13,7 +13,7 @@
       it 'records Redis commands' do
         recorder = described_class.new { cache.read('key1') }
 
-        expect(recorder.log).to include(['get', 'cache:gitlab:key1'])
+        expect(recorder.log).to include([:get, 'cache:gitlab:key1'])
       end
     end
 
@@ -35,10 +35,10 @@
         cache.delete('key1')
       end
 
-      expect(recorder.log).to include(['set', 'cache:gitlab:key1', anything, anything, anything])
-      expect(recorder.log).to include(['get', 'cache:gitlab:key1'])
-      expect(recorder.log).to include(['get', 'cache:gitlab:key2'])
-      expect(recorder.log).to include(['del', 'cache:gitlab:key1'])
+      expect(recorder.log).to include([:set, 'cache:gitlab:key1', anything, anything, anything])
+      expect(recorder.log).to include([:get, 'cache:gitlab:key1'])
+      expect(recorder.log).to include([:get, 'cache:gitlab:key2'])
+      expect(recorder.log).to include([:del, 'cache:gitlab:key1'])
     end
 
     it 'does not record commands before the call' do
@@ -48,8 +48,8 @@
         cache.read('key1')
       end
 
-      expect(recorder.log).not_to include(['set', anything, anything])
-      expect(recorder.log).to include(['get', 'cache:gitlab:key1'])
+      expect(recorder.log).not_to include([:set, anything, anything])
+      expect(recorder.log).to include([:get, 'cache:gitlab:key1'])
     end
 
     it 'refreshes recording after reinitialization' do
@@ -68,15 +68,15 @@
         cache.read('key4')
       end
 
-      expect(recorder1.log).to include(['get', 'cache:gitlab:key2'])
-      expect(recorder1.log).not_to include(['get', 'cache:gitlab:key1'])
-      expect(recorder1.log).not_to include(['get', 'cache:gitlab:key3'])
-      expect(recorder1.log).not_to include(['get', 'cache:gitlab:key4'])
+      expect(recorder1.log).to include([:get, 'cache:gitlab:key2'])
+      expect(recorder1.log).not_to include([:get, 'cache:gitlab:key1'])
+      expect(recorder1.log).not_to include([:get, 'cache:gitlab:key3'])
+      expect(recorder1.log).not_to include([:get, 'cache:gitlab:key4'])
 
-      expect(recorder2.log).to include(['get', 'cache:gitlab:key4'])
-      expect(recorder2.log).not_to include(['get', 'cache:gitlab:key1'])
-      expect(recorder2.log).not_to include(['get', 'cache:gitlab:key2'])
-      expect(recorder2.log).not_to include(['get', 'cache:gitlab:key3'])
+      expect(recorder2.log).to include([:get, 'cache:gitlab:key4'])
+      expect(recorder2.log).not_to include([:get, 'cache:gitlab:key1'])
+      expect(recorder2.log).not_to include([:get, 'cache:gitlab:key2'])
+      expect(recorder2.log).not_to include([:get, 'cache:gitlab:key3'])
     end
   end
 
@@ -91,10 +91,10 @@
         cache.delete('key2')
       end
 
-      expect(recorder.log).to include(['set', 'cache:gitlab:key1', anything, anything, anything])
-      expect(recorder.log).to include(['get', 'cache:gitlab:key1'])
-      expect(recorder.log).not_to include(['get', 'cache:gitlab:key2'])
-      expect(recorder.log).not_to include(['del', 'cache:gitlab:key2'])
+      expect(recorder.log).to include([:set, 'cache:gitlab:key1', anything, anything, anything])
+      expect(recorder.log).to include([:get, 'cache:gitlab:key1'])
+      expect(recorder.log).not_to include([:get, 'cache:gitlab:key2'])
+      expect(recorder.log).not_to include([:del, 'cache:gitlab:key2'])
     end
   end
 
@@ -107,7 +107,7 @@
         cache.delete('key2')
       end
 
-      expect(recorder.by_command('del')).to match_array([['del', 'cache:gitlab:key2']])
+      expect(recorder.by_command(:del)).to match_array([[:del, 'cache:gitlab:key2']])
     end
   end
 
diff --git a/spec/workers/gitlab/github_gists_import/import_gist_worker_spec.rb b/spec/workers/gitlab/github_gists_import/import_gist_worker_spec.rb
index 4cb0a25f89294..d11b044b093bc 100644
--- a/spec/workers/gitlab/github_gists_import/import_gist_worker_spec.rb
+++ b/spec/workers/gitlab/github_gists_import/import_gist_worker_spec.rb
@@ -108,24 +108,17 @@
 
         before do
           allow(importer).to receive(:execute).and_return(importer_result)
-          allow(Gitlab::GithubGistsImport::Representation::Gist)
-            .to receive(:from_json_hash)
-            .with(anything)
-            .and_return(gist_object)
         end
 
         it 'tracks and logs error' do
-          # use `anything` since jid is created in Sidekiq's middleware. `jid` does not exist until
-          # perform_inline is called.
           expect(Gitlab::GithubImport::Logger)
             .to receive(:error)
-            .with(log_attributes.merge('message' => 'importer failed', 'exception.message' => 'error_message',
-              'jid' => anything))
+            .with(log_attributes.merge('message' => 'importer failed', 'exception.message' => 'error_message'))
           expect(Gitlab::JobWaiter)
             .to receive(:notify)
-            .with('some_key', anything, ttl: Gitlab::Import::JOB_WAITER_TTL)
+            .with('some_key', subject.jid, ttl: Gitlab::Import::JOB_WAITER_TTL)
 
-          subject.class.perform_inline(user.id, gist_hash, 'some_key') # perform_inline calls .perform
+          subject.perform(user.id, gist_hash, 'some_key')
 
           expect_snowplow_event(
             category: 'Gitlab::GithubGistsImport::ImportGistWorker',
@@ -137,7 +130,7 @@
         end
 
         it 'persists failure' do
-          expect { subject.class.perform_inline(user.id, gist_hash, 'some_key') }
+          expect { subject.perform(user.id, gist_hash, 'some_key') }
             .to change { ImportFailure.where(user: user).count }.from(0).to(1)
 
           expect(ImportFailure.where(user_id: user.id).first).to have_attributes(
diff --git a/spec/workers/gitlab/jira_import/import_issue_worker_spec.rb b/spec/workers/gitlab/jira_import/import_issue_worker_spec.rb
index c9c4c6e2d2b52..3b2114ee48813 100644
--- a/spec/workers/gitlab/jira_import/import_issue_worker_spec.rb
+++ b/spec/workers/gitlab/jira_import/import_issue_worker_spec.rb
@@ -46,7 +46,7 @@
 
       context 'when import label does not exist' do
         it 'does not record import failure' do
-          subject.class.perform_inline(project.id, 123, issue_attrs, some_key)
+          subject.perform(project.id, 123, issue_attrs, some_key)
 
           expect(label.issues.count).to eq(0)
           expect(Gitlab::Cache::Import::Caching.read(Gitlab::JiraImport.failed_issues_counter_cache_key(project.id)).to_i).to eq(0)
@@ -57,7 +57,7 @@
         before do
           Gitlab::JiraImport.cache_import_label_id(project.id, label.id)
 
-          subject.class.perform_inline(project.id, 123, issue_attrs, some_key)
+          subject.perform(project.id, 123, issue_attrs, some_key)
         end
 
         it 'does not record import failure' do
diff --git a/spec/workers/redis_migration_worker_spec.rb b/spec/workers/redis_migration_worker_spec.rb
index d8f2cacdb13e5..9f29c84a94895 100644
--- a/spec/workers/redis_migration_worker_spec.rb
+++ b/spec/workers/redis_migration_worker_spec.rb
@@ -30,7 +30,7 @@ def scan_match_pattern
         end
 
         def redis
-          ::Gitlab::Redis::Cache.redis
+          ::Redis.new(::Gitlab::Redis::Cache.params)
         end
       end
     end
-- 
GitLab