Server Side Debouncing CableReady::Updatable - A Large-Scale Optimization Case Study

One of the downsides of using CableReady::Updatable in the wild is that it can lead to a lot of noise on the ActionCable connection. This is because every change on a model that enables updates leads to a ping being sent over ActionCable due to the usage of ActiveRecord callbacks. In one particular large-scale Rails app, this led to considerable cost and performance penalties due to excessive Redis usage. In this article we'll dive into the problem and how it was mitigated.

Defining the Desired Outcome

Being a good citizen, I started speccing out the desired behavior in a test:

test "only sends out a ping once per debounce period" do
  site = Site.create(name: "Front Page")

    mock_server = mock("server")
    mock_server.expects(:broadcast).with(Site, {changed: ["name", "updated_at"]}).twice
    mock_server.expects(:broadcast).with(site.to_global_id, {changed: ["name", "updated_at"]}).twice

    ActionCable.stubs(:server).returns(mock_server)

    # debounce time is 3 seconds, so the last update should trigger its own broadcast
    site.update(name: "Landing Page 1")
    travel(1.second)
    site.update(name: "Landing Page 2")
    travel(3.seconds)
    site.update(name: "Landing Page 3")
  end

In the above test, we're simulating the behavior of the CableReady::Updatable. We create a site and then mock the ActionCable server. We then expect the server to broadcast changes to the site's name and updated_at attributes. The travel method simulates the passage of time, allowing us to test the debouncing behavior.

Implementing Debouncing in CableReady::Updatable

I then patched the broadcast_updates method in the CableReady::Updatable module to debounce updates.

  def broadcast_updates(model_class, options)
    return if skip_updates_classes.any? { |klass| klass >= self }
    raise("ActionCable must be enabled to use Updatable") unless defined?(ActionCable)
    ActionCable.server.broadcast(model_class, options)

    debounce_time = options.delete(:debounce)
    debounce_time ||= CableReady.config.updatable_debounce_time

    if debounce_time.to_f > 0
      key = compound([model_class, *options])
      old_wait_until = CableReady::Updatable.debounce_adapter[key]
      now = Time.now.to_f

      if old_wait_until.nil? || old_wait_until < now
        new_wait_until = now + debounce_time.to_f
        CableReady::Updatable.debounce_adapter[key] = new_wait_until
        ActionCable.server.broadcast(model_class, options)
      end
    else
      ActionCable.server.broadcast(model_class, options)
    end
  end

The broadcast_updates method is responsible for sending updates to the client. The debouncing logic ensures that updates are not sent too frequently. If the time since the last update is less than the debounce time, the update is skipped. This reduces the number of unnecessary updates sent to the client, optimizing performance.

Introducing the MemoryCacheDebounceAdapter

The last missing piece is implementing the mentioned debounce_adapter:

  class MemoryCacheDebounceAdapter
    include Singleton

    delegate_missing_to :@store

    def initialize
      super
      @store = ActiveSupport::Cache::MemoryStore.new(expires_in: 5.minutes, size: 8.megabytes)
    end

    def []=(key, value)
      @store.write(key, value)
    end

    def [](key)
      @store.read(key)
    end
  end

Note that this default implementation uses the built in cache memory store, thus providing debouncing only within a single process. This interface is already thread-safe and can be used right out of the box. Preparations are made, however, to specify a different adapter in the config, making cross-process debouncing possible, e.g. using a Redis cache store.

Conclusion

Debouncing is a powerful technique to optimize server-client communication. By reducing unnecessary pings on the ActionCable connection, we not only optimize performance but also curtail associated costs, as fewer or less powerful Redis instances are necessary. As applications continue to grow, embracing such techniques becomes pivotal for maintaining seamless user experiences and allow for effective budgeting.

Supercharge Your Rails App

Upgrade Your Tech Stack for a Smoother Dev Experience and Increased Profits.

Order a Review