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