This post is part of Develop Single-Machine Rails Applications with LiteStack Series
- 1An Introduction to LiteStack for Ruby on Rails
- 2A Deep Dive Into LiteDB for Ruby on Rails
- 3Handle Incoming Webhooks with LiteJob for Ruby on Rails
- 4Stream Updates to Your Users with LiteCable for Ruby on Rails
- 5Speed Up Your Ruby on Rails Application with LiteCache
- 6Full-Text Search for Ruby on Rails with Litesearch
In parts one and two of this series, we only dealt with the pure CRUD aspects of using SQLite as a production database.
In this post, we will explore the world of queue mechanisms, using SQLite as the pub/sub adapter for ActiveJob.
Let's make use of LiteJob to handle incoming webhooks in our Rails application.
Our Ruby on Rails Use Case
Our use case is the image generation that we are currently doing synchronously in the PromptsController
:
Clearly, there are some long-running processes here that should be deferred to a background job: the loading of the model, and the prediction itself.
Configuration
In your environment configuration files, ensure you reference LiteJob as the queue adapter:
Now we're all set to continue our investigation. Don't forget to restart your development server, though!
If you like, you can add more ActiveJob configuration in config/litejob.yml
— for example, queue priorities:
Please refer to the README for further details.
That's set up, so let's move on to generating our images asynchronously.
Generating Images Asynchronously in Our Ruby on Rails Application
We'll start by scaffolding our job with the usual Rails generator command:
Let's move the image generation code from PromptsController
into the perform
method of this job:
Note that we have to include the URL helpers module here to access replicate_rails_url
(as opposed to controllers, where this happens automatically). Furthermore, we also need to explicitly specify the host
— we grab it off ActionMailer's default_url_options
, in this case. Look at Andy Croll's excellent 'Use Rails URL helpers outside views and controllers' post for more sophisticated uses.
To make this work with the incoming webhook, make sure you also reference your ngrok URL in your app's configuration:
As the final step on the requesting side of our call to Replicate, we have to actually call the job from PromptsController
:
Persisting Replicate.com Predictions
Now it's time to take a look at the receiving end of our prediction request, i.e., the incoming webhook.
Right now, generated predictions are handed back to us, but we don't do anything with them. Since images are purged from Replicate's CDN periodically, we want to store them locally.
Let's start by generating a new child model, Prediction
. We'll prepare it to store the generated image as a binary blob again:
On top of that, we create columns to store some metadata returned from Replicate.com: id
, version
, and logs
— see the API docs.
Finally, we also have to register this new association in the Prompt
model:
Now we just have to process the incoming predictions in our webhook, but we face an issue. We have to find a way to identify the specific Prompt
instance the prediction was created for. We can circumvent this problem by adding a query param to the incoming webhook URL:
This will effectively send our incoming webhook to YOUR_NGROK_URL/replicate/webhook?sgid=YOUR_RECORD_SGID
, making it easy to identify the prompt.
Obtain the sgid
in Ruby
Sadly, the replicate-rails
gem's default controller doesn't pass the query parameters to the webhook handler, but it does pass the exact webhook URL.
So we can flex our Ruby standard library muscles to obtain the sgid
from it:
Let's dissect this:
- First, we pluck the exact webhook URL off the incoming
prediction
object and parse it, returning thequery
string. - Next, we invoke
CGI.parse
to convert the query string into a hash. Accessing the"sgid"
key returns a one-element array, which is why we have to sendfirst
to it. - Then, we can use the obtained
sgid
to locate theprompt
instance. - Finally, we append a new prediction to our prompt using the properties returned from Replicate.com. We also download the prediction image and save it in SQLite. Note that we can skip resizing because the generated image will already have the correct dimensions.
There's one final bit we have to tend to: we have to make sure our webhook handler works in an idempotent way. Webhooks can arrive duplicated or out of order, so we have to prepare for this case.
Luckily, we can use the ID returned by replicate to ensure idempotency. All we have to do is add a unique index to the replicate_id
column. This will make it impossible to add a duplicate prediction to our database:
Keep in mind that you typically would still want to store your assets and attachments in object storage buckets like Amazon S3 or DigitalOcean spaces. To get a glimpse of what SQLite can do, we have opted to store and render them directly from the database here.
To complete our picture — pun intended — we again have to find a way to display our stored predictions as children of a prompt. We can do this using a data URL:
A Look Behind the Scenes of LiteJob for Ruby on Rails
Let's quickly look into how LiteJob uses SQLite to implement a job queueing system. In essence, the class Litequeue
interfaces with the SQLite queue
table. This table's columns, like id
, name
, fire_at
, value
, and created_at
, store and manage job details.
The push
and repush
methods of the Litequeue
class add jobs to the queue, interfacing with their respective SQL statements. When it's time for a job's execution, the pop
method in the same class retrieves and removes a job based on its scheduled time. The delete
method allows for specific job removal.
The Litejobqueue
class is a subclass of Litequeue
, which, upon initialization, is tasked with creating the worker/s. These workers contain the main run loop that oversees the actual job execution, continuously fetching and running jobs from the queue.
Limitations of LiteJob
Now that we've broken down how to use LiteJob for asynchronous job processing, let's look at some of the potential drawbacks ensuing from this approach.
While an SQLite-based architecture provides simplicity and reduces the need for external dependencies, it also introduces several limitations.
Firstly, LiteJob's reliance on SQLite inherently restricts its horizontal scaling capabilities. Unlike other databases, SQLite is designed for single-machine use, making it challenging to distribute workload across multiple servers. This can certainly be done using novel technologies like LiteFS, but it is far from intuitive.
Additionally, even on the same machine, concurrency contends with your web server (e.g., Puma) because LiteJob spawns threads or fibers that compete with those handling the web requests.
On the other hand (and crucially, for the majority of smaller apps), you might never need such elaborate scaling concepts.
LiteJob's Performance in Benchmarks
On a more positive note, LiteJob seems to allow for a lot of overhead before you have to scale horizontally: at least the benchmarks put me in a cautiously euphoric mood. Granted, the benchmarks don't carry a realistic payload, but they demonstrate that LiteJob makes very efficient use of threads and fibers.
Up Next: Streaming Updates with LiteCable
In this installment of our series, we delved deeper into the intricacies of using SQLite with LiteJob to handle asynchronous image generation and incoming webhooks in Rails applications.
To recap, while the SQLite-based architecture of LiteJob offers simplicity and reduced external dependencies, it also presents challenges in horizontal scaling. However, for smaller applications, LiteJob's efficiency with threads and fibers suggests it can handle a significant workload before necessitating more complex scaling solutions.
In the next post of this series, we'll look at providing reactive updates to our users using Hotwire powered by LiteCable.
Until then, happy coding!
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!
This post is part of Develop Single-Machine Rails Applications with LiteStack Series
- 1An Introduction to LiteStack for Ruby on Rails
- 2A Deep Dive Into LiteDB for Ruby on Rails
- 3Handle Incoming Webhooks with LiteJob for Ruby on Rails
- 4Stream Updates to Your Users with LiteCable for Ruby on Rails
- 5Speed Up Your Ruby on Rails Application with LiteCache
- 6Full-Text Search for Ruby on Rails with Litesearch