Stream Updates to Your Users with LiteCable for Ruby on Rails
Tags: ruby, rails
Published: 2024-01-10 14:00:00

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`:

```yaml
development:
  adapter: litecable

test:
  adapter: test

staging:
  adapter: litecable

production:
  adapter: litecable
```

## 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.

```diff
  # 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
```

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).

```diff
  # 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
```

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:

```diff
  <!-- 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>
```

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](https://www.rubydoc.info/github/hotwired/turbo-rails/Turbo/Broadcastable). Before we can do that, though, let's separate out a model partial for `Prediction`:

```erb
<!-- 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>
```

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:

```diff
  <!-- 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>
```

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

```diff
  # 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
```

### 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](https://blog.appsignal.com/images/blog/2023-12/image-prediction.mp4) - note that I'm using [Shoelace components](https://shoelace.style/) 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](https://github.com/oldmoe/litestack/blob/master/BENCHMARKS.md) 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](https://anycable.io/) 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](https://fly.io/docs/litefs/) will have to offer.

Continuous monitoring of your app's WebSocket performance metrics using [tools like AppSignal](https://www.appsignal.com/ruby) 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](https://anycable.io/), 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](https://blog.appsignal.com/ruby-magic)!**