DEV Community

Cover image for Stream Updates to Your Users with LiteCable for Ruby on Rails
julianrubisch for AppSignal

Posted on • Originally published at blog.appsignal.com

Stream Updates to Your Users with LiteCable for Ruby on Rails

So far in this series, we have been exploring the capabilities of SQLite for classic HTTP request/response type usage. In this post, we will push the boundary further by also using SQLite as a Pub/Sub adapter for ActionCable, i.e., WebSockets.

This is no small feat: WebSocket adapters need to handle thousands of concurrent connections performantly. The emergence of alternatives to ActionCable β€” read AnyCable β€” bears witness to the fact that this is a pressing concern for modern web applications. We'll take a look at how SQLite performs under these conditions.

But first, let's set up our app to broadcast streaming updates to users via Turbo Streams.

Configure Your Ruby on Rails App to Use LiteCable for Websockets

Configuring your application to use LiteCable for WebSocket connections is as easy as specifying the adapter in config/cable.yml:

development:
  adapter: litecable

test:
  adapter: test

staging:
  adapter: litecable

production:
  adapter: litecable
Enter fullscreen mode Exit fullscreen mode

Preparing Our Rails App for Live Updates

Before we dive deep into how to broadcast model updates using Turbo Rails, we need to rework the mechanics of creating a prediction.

First, we'll create an empty prediction in GenerateImageJob to display a placeholder in our _prompt partial. This has the added benefit of forwarding the actual prediction's SGID to the webhook. Note, though, that we also have to pass the account's SGID, because the incoming webhook doesn't have any session information about the currently active user.

  # app/jobs/generate_image_job.rb

  class GenerateImageJob < ApplicationJob
    include Rails.application.routes.url_helpers

    queue_as :default

    def perform(prompt:)
+     empty_prediction = prompt.predictions.create

      model = Replicate.client.retrieve_model("stability-ai/stable-diffusion-img2img")
      version = model.latest_version
      version.predict({prompt: prompt.title, image: prompt.data_url},
        replicate_rails_url(host: Rails.application.config.action_mailer.default_url_options[:host],
-         params: {sgid: prompt.to_sgid.to_s}))
+         params: {prediction: empty_prediction.to_sgid.to_s,
+         account: prompt.account.to_sgid.to_s}))
    end
  end
Enter fullscreen mode Exit fullscreen mode

In parallel, in our ReplicateWebhook, we can locate and simply update the prediction. Note that we have to set the Current.account because Prompt is scoped to an account and would otherwise end up empty (due to the way AccountScoped is set up).

  # config/initializers/replicate.rb

  class ReplicateWebhook
    def call(prediction)
      query = URI(prediction.webhook).query

-     sgid = CGI.parse(query)["sgid"].first
+     prediction_sgid = CGI.parse(query)["prediction"].first
+     account_sgid = CGI.parse(query)["account"].first

-     prompt = GlobalID::Locator.locate_signed(sgid)
+     located_prediction = GlobalID::Locator.locate_signed(sgid)
+     Current.account = GlobalID::Locator.locate_signed(account_sgid)

-     prompt.predictions.create(
+     located_prediction.update(
        prediction_image: URI.parse(prediction.output.first).open.read,
        replicate_id: prediction.id,
        replicate_version: prediction.version,
        logs: prediction.logs
      )
    end
  end
Enter fullscreen mode Exit fullscreen mode

This change entails that the created prediction is empty, i.e., has no prediction image (obviously). Let's cater for this by adding a conditional to our _prompt.html.erb partial. When the image is missing, we display a spinner:

  <!-- app/views/prompts/_prompt.html.erb -->

  <p>
    <strong>Generated images:</strong>
    <% prompt.predictions.each do |prediction| %>
+     <% if prediction.prediction_image.present? %>
        <%= image_tag prediction.data_url %>
+     <% else %>
+       <sl-spinner style="font-size: 8rem;"></sl-spinner>
+     <% end %>
    <% end %>
  </p>
Enter fullscreen mode Exit fullscreen mode

Great, we're done preparing our app to deliver live updates. Let's implement Turbo-Rails model broadcasts to finish this proof of concept.

Delivering Prediction Updates Live with Turbo-Rails

To test the WebSocket capabilities of LiteStack, we are going to use Turbo::Broadcastable. We'd like to show the spinner and the generated image once it has been created.

The way to do that is quite idiomatic: We tie this to after_create_commit and after_update_commit model callbacks invoking one of Turbo::Broadcastable's broadcast methods. Before we can do that, though, let's separate out a model partial for Prediction:

<!-- app/views/predictions/_prediction.html.erb -->
<%= turbo_stream_from prediction %>

<div id="<%= dom_id(prediction) %>">
  <% if prediction.prediction_image.present? %>
    <%= image_tag prediction.data_url %>
  <% else %>
    <sl-spinner style="font-size: 8rem;"></sl-spinner>
  <% end %>
</div>
Enter fullscreen mode Exit fullscreen mode

Observe that I added a turbo_stream_from tag to the partial, containing the stream identifier and subscribing to the channel. We can now simply call render from the prompt partial and add another turbo_stream_from to listen for changes to the prediction list:

  <!-- app/views/prompts/_prompt.html.erb -->

  <p>
    <strong>Generated images:</strong>
-   <% prompt.predictions.each do |prediction| %>
-     <% if prediction.prediction_image.present? %>
-       <%= image_tag prediction.data_url %>
-     <% else %>
-       <sl-spinner style="font-size: 8rem;"></sl-spinner>
-     <% end %>
-   <% end %>
+   <%= turbo_stream_from :predictions %>
+   <div id="<%= dom_id(prompt, :predictions) %>">
+     <%= render prompt.predictions %>
+   </div>
  </p>
Enter fullscreen mode Exit fullscreen mode

Now we're ready to set up model broadcasts. In the Prediction class, we add two model callbacks, invoking two Turbo Stream actions.

  # app/models/prediction.rb

  class Prediction < ApplicationRecord
+   include ActionView::RecordIdentifier

+   after_create_commit -> { broadcast_append_later_to :predictions,
+     target: dom_id(prompt, :predictions) }
+   after_update_commit -> { broadcast_replace_later_to self }

    belongs_to :prompt

    def data_url
      encoded_data = Base64.strict_encode64(prediction_image)

      "data:image/png;base64,#{encoded_data}"
    end
  end
Enter fullscreen mode Exit fullscreen mode

What's Happening Here?

First, when a prediction is created, we append it to the predictions list. This will show our loading spinner once GenerateImageJob has run.

Then, every update to the record will trigger a replace of the prediction partial. Once the prediction is updated in ReplicateWebhook, the image returned from Replicate displays.

Here's what this looks like - note that I'm using Shoelace components for styling purposes.

Benchmarks: LiteCable Vs. Redis

So far, this article has shown that it's possible to run ActionCable with LiteCable as its adapter. This is a nice proof of concept, but we're here to check how LiteStack compares to other adapters as well.

Luckily, the official LiteStack benchmarks include measurements for LiteCable against Redis, which I am going to quote here.

Here's a small but important caveat: All these measurements were performed on the same machine. In typical production setups with managed Redis, you'll have to factor in additional network latency.

Let's look at the requests per second metric first. This captures how many Pub/Sub requests the Redis and SQLite processes are able to serve.

Requests Redis requests/second LiteStack requests/second
1,000 2611 3058
10,000 3110 5328
100,000 3403 5385

Note that LiteStack is able to process more requests per second, but tapers off with higher loads. Though not representative, this might be an issue for loads of 1M requests and beyond β€” but that's when you'll typically reach for faster solutions like AnyCable over stock ActionCable anyway.

Furthermore, there are some latency tests included in the benchmarks.

Requests Redis p90 Latency LiteStack p90 Latency Redis p99 Latency LiteStack p99 Latency
1,000 34 27 153 78
10,000 81 40 138 122
100,000 41 36 153 235

Allowing for some inaccuracy of measurement, both perform equivalently in this regard, maybe with the exception of the 99 percentile. Here, SQLite's locking model interferes with the amount of concurrent requests.

Again keep in mind, though, that you'll have to add a couple of milliseconds of latency once Redis runs on a different machine (LiteCable always runs on the same machine by design).

Limitations of SQLite for Rails

It is fair to assume that once you hit a certain level of Pub/Sub activity, you'll reach the ceiling of what's possible with a single SQLite database. That's the moment when you'll have to think about sharding, and here other technologies like Redis have a head start β€” though it will be interesting to see what LiteFS will have to offer.

Continuous monitoring of your app's WebSocket performance metrics using tools like AppSignal is your friend here. Reusing the ActionCable consumer on the client side is also advisable, as it will prevent wasting Pub/Sub connections.

LiteCable is tailored for vertical scaling by a tight integration of components. If you extract maximum performance from the SQLite engine, the limits of this approach are pushed a lot further. Once you observe that your latencies start to explode, though, I would suggest researching options like AnyCable, which inherently provide better strategies for horizontal scaling.

Up Next: Speed Up Rails App Rendering with LiteCache

In this post, we explored using SQLite as a Pub/Sub adapter for ActionCable to enable real-time updates in a Rails application via WebSockets. Configuring LiteCable was straightforward, requiring just a simple adapter specification. Leveraging Turbo::Broadcastable model callbacks made our implementation clean, tying broadcasts to creation and updates.

Though powerful, LiteCable is not designed to scale across multiple processes or servers. But for single-machine deployments, it unlocks real-time features in Rails without requiring a separate Redis instance.

Our next post will look at the next puzzle piece in LiteStack: the ActiveSupport cache store it provides. We'll test out how it can help us to lower server response times, and look at some benchmarks again.

See you then!

P.S. If you'd like to read Ruby Magic posts as soon as they get off the press, subscribe to our Ruby Magic newsletter and never miss a single post!

Top comments (0)