An Introduction to LiteStack for Ruby on Rails
Tags: ruby, rails
Published: 2023-10-04 11:00:00

In this series of posts, we will look at **LiteStack**, a one-stop-shop solution that hosts and processes all your production data on a single machine. LiteStack (as the name suggests) makes use of SQLite to provide:

- a database using the _LiteDB_ adapter
- an ActiveJob backend (_LiteJob_)
- an ActionCable backend (_LiteCable_)
- an ActiveSupport::Cache store (_LiteCache_)

In this first post, we'll introduce the basics of LiteStack and set up an example Rails application.

Let's begin!

## An Introduction to SQLite

[SQLite](https://www.sqlite.org/index.html) itself has been the go-to embedded database of many industries for decades. For example, it's widely used in [native app development, testing environments, caching, and others](https://www.sqlite.org/whentouse.html).

Recently, though, it has attracted a lot of experimentation and extensions. One of the most popular extensions is [Litestream](https://litestream.io/), which can recover stream changes to an S3-compatible bucket. This means you get a replica of your production database at a very cheap price point and can recover from failure anytime.

Incidentally, this has made using SQLite as a production database for your Rails app a feasible option. Combined with a full-fledged development stack like LiteStack, it promises to make apps hosted on a single machine a reality. We are here to test this hypothesis and point out any obstacles in the way.

To do this, we need an example app that's complex enough to surface potential difficulties, but simple enough to fit this series.

## Our Example Rails Application

We will write an app that transforms children's drawings using [StableDiffusion on replicate.com](https://replicate.com/cjwbw/stable-diffusion-img2img-v2.1).

As an example, here's a cute teddy bear drawn by my daughter, with a couple of StableDiffusion interpretations:

![Teddybears](https://blog.appsignal.com/images/blog/2023-09/teddybears.png)

As a rough sketch, our app will cover the following steps:

1.  The user uploads an image with a textual prompt (we'll show some advanced SQLite techniques here).
2.  The user chooses an image style (e.g., "cartoon", "oil painting", "photorealistic", "3D rendering").
3.  The processing happens in the background (this kicks off a _LiteJob_-powered job).
4.  While the processing is underway, we show a placeholder image and update the logs sent over by the server. Once completed, we update it to show the actual image. This allows us to explore _LiteCable_ as we replace the image via Turbo Streams.
5.  We store the image prediction.
6.  We use _LiteCache_ to wrap computationally costly views.

These steps provide a scaffold for this series. The remainder of this post, though, will be concerned with setting up the app.

We start by creating a new Rails app called _skAItch_, using esbuild as our JavaScript bundler and SASS as the CSS preprocessor:

```sh
$ rails new -d sqlite3 --skip-action-mailbox -j esbuild -c sass skaitch
$ cd skaitch
$ bin/rails g action_text:install
$ bin/rails db:migrate
```

## Install LiteStack

Next, we install [LiteStack](https://github.com/oldmoe/litestack) using the shipped generator:

```sh
$ bundle add litestack
$ bin/rails g litestack:install
```

After that is done, we complete the setup and start the development server:

```sh
$ bin/setup
$ bin/dev
```

## Authentication and Tenants

Subsequently, we need a way to authenticate our users to associate prompts with them. Rather than using an incumbent like Devise, I chose to use a different approach. The [authentication-zero gem](https://github.com/lazaronixon/authentication-zero) can flexibly generate an authentication system, as opposed to including it as an engine. Conveniently, it comes with options such as:

- authentication by token (for APIs)
- two-factor auth
- multitenancy
- rate limiting
- an OmniAuth interface
- passwordless auth

I've chosen to add the `--tenantable` option because it's always a good idea to automatically scope your database records to accounts. Authentication-zero provides this with the `AccountScoped` model concern.

```sh
$ bundle add authentication-zero --group development
$ bin/rails g authentication --tenantable
$ bin/rails db:migrate
```

Let's add a first user via database seeds:

```ruby
# db/seeds.rb
User.create(
  email: "julian@example.com",
  password: "mypassword123",
  password_confirmation: "mypassword123",
  verified: true
)
```

## Prompt Scaffold

Now it's time to start writing our actual application logic. We start by defining the central model of our app: the `Prompt`.

We want our prompt to have a title, a description, and a reference to the account that created it. Furthermore, to test SQLite's "file system" capabilities, we would like it to store the prompt image in binary form:

```sh
$ bin/rails g scaffold Prompt title:string description:rich_text prompt_image:binary account:references
$ bin/rails db:migrate
```

A `prompts` resources entry is also added to `config/routes.rb`. Note that authentication-zero adds a `before_action` authenticating the user to `ApplicationController` by default.

```diff
  # config/routes.rb
    Rails.application.routes.draw do
+     resources :prompts

      get  "sign_in", to: "sessions#new"
      post "sign_in", to: "sessions#create"
      get  "sign_up", to: "registrations#new"
      post "sign_up", to: "registrations#create"
      resources :sessions, only: [:index, :show, :destroy]
      resource  :password, only: [:edit, :update]
      namespace :identity do
        resource :email,              only: [:edit, :update]
        resource :email_verification, only: [:show, :create]
        resource :password_reset,     only: [:new, :edit, :create, :update]
      end
      root "home#index"
    end
```

Furthermore, we include the `AccountScoped` concern in our Prompt model, which allows us to scope the stored records by logged-in account. We also validate that a prompt title and image are present.

```diff
  # app/models/prompt.rb
  class Prompt < ApplicationRecord
+   include AccountScoped

    belongs_to :account
    has_rich_text :description

+   validates :title, :prompt_image, presence: true
  end
```

As the final step to enable multitenancy, we have to connect the prompt to an account when it is created. We do this in the `PromptsController`:

```diff
  # app/controllers/prompts_controller.rb
  class PromptsController < ApplicationController

    # other actions omitted

    def create
      @prompt = Prompt.new(prompt_params)
+     @prompt.account = Current.account

      respond_to do |format|
        if @prompt.save
          format.html { redirect_to prompt_url(@prompt), notice: "Prompt was successfully created." }
          format.json { render :show, status: :created, location: @prompt }
        else
          format.html { render :new, status: :unprocessable_entity }
          format.json { render json: @prompt.errors, status: :unprocessable_entity }
        end
      end
    end

    # ...
  end
```

## Connecting our Rails Application to Replicate.com

[Replicate.com](https://replicate.com/) is a leading platform for running AI predictions on high-performance graphics cards. It features an [API](https://replicate.com/docs/reference/http) to create predictions, train and store models, etc. To use it, you have to obtain an API token from [https://replicate.com/account/api-tokens](https://replicate.com/account/api-tokens).

**Note:** Running predictions on Replicate is subject to a charge.

Fortunately for us, there are official and unofficial clients that interface with the API. One such wrapper is the [replicate-rails](https://github.com/dreamingtulpa/replicate-rails) gem, which we will install now:

```diff
  # Gemfile

  # ... other gems

+ gem 'replicate-rails', require: 'replicate_rails'
```

```sh
$ bundle install
```

To store our API key securely, we are going to use Rails credentials:

```sh
$ EDITOR=vim bin/rails credentials:edit
```

```diff
  # config/credentials.yml.enc
  secret_key_base: YOUR_SECRET_KEY_BASE

+ replicate:
+   api_token: YOUR_REPLICATE_API_TOKEN
```

Editing and saving this file will encrypt your credentials, and it can only be opened by providing the correct `RAILS_MASTER_KEY`.

Now, we have to put it to use. As suggested in replicate-rails' README, we authenticate against Replicate in an initializer. We also define a webhook handler which (in our case) is just a class put into the same file. Note that I added a `binding.irb` breakpoint here for a first test of our functionality.

```ruby
# config/initializers/replicate.rb
Replicate.client.api_token = Rails.application.credentials[:replicate][:api_token]

class ReplicateWebhook
  def call(prediction)
    binding.irb
  end
end

ReplicateRails.configure do |config|
  config.webhook_adapter = ReplicateWebhook.new
end
```

Replicate-rails also ships a default webhook controller, which calls the above handler. We only have to mount it in our `config/routes.rb`:

```diff
  # config/routes.rb
  Rails.application.routes.draw do
+   mount ReplicateRails::Engine => "/replicate/webhook"

    resources :prompts

    # ... more routes omitted
  end
```

To test the webhook locally, you have to set up a tunnel, for example, with [Ngrok](https://ngrok.com/). The steps to set it up are beyond the scope of this article, but it's pretty simple. Please refer to the [Ngrok docs](https://ngrok.com/docs).

You do, however, have to tell Rails that it may listen to your tunnel's URL. To enable this, add it to the `allowed_hosts` in `config/application.rb`:

```diff
  # ...requires omitted

  # config/application.rb
  module Skaitch
    class Application < Rails::Application
      # Initialize configuration defaults for originally generated Rails version.
      config.load_defaults 7.0

      # Configuration for the application, engines, and railties goes here.
      #
      # These settings can be overridden in specific environments using the files
      # in config/environments, which are processed later.
      #
      # config.time_zone = "Central Time (US & Canada)"
      # config.eager_load_paths << Rails.root.join("extras")
+     config.hosts << "YOUR_NGROK_URL"
    end
  end
```

## Running a Prediction

Now we'll test creating a prediction. The only missing bit is to hook it up in our `PromptsController`. The general workflow is as follows:

1.  We retrieve a model (in our case, the `stable-diffusion-img2img` model) from Replicate.
2.  We grab a specific version (in our case, the latest one) to run the prediction against.
3.  We run the prediction, specifying:
    - A text prompt describing the image (in our case, our prompt's title).
    - An image prompt that we have to provide as a Base64-encoded Data URL.
    - A webhook to ping when the prediction completes (we point it at the webhook route provided by replicate-rails).

```diff
# app/controllers/prompts_controller.rb
  class PromptsController < ApplicationController

    # other actions omitted

    def create
      @prompt = Prompt.new(prompt_params)
      @prompt.account = Current.account

+     model = Replicate.client.retrieve_model("stability-ai/stable-diffusion-img2img")
+     version = model.latest_version
+     version.predict({prompt: prompt_params[:title], image: prompt_image_data_url}, replicate_rails_url)

      # respond_to omitted
    end

    # ...

    private

    # other private methods omitted

+   def prompt_image_data_url
+     encoded_data = Base64.strict_encode64(prompt_image.read)
+
+     "data:image/#{prompt_image.content_type};base64,#{encoded_data}"
+   end
+
+   def prompt_image
+     @prompt_image ||= prompt_params[:prompt_image]
+   end
  end
```

If you go to [https://YOUR_NGROK_URL/prompts/new](https://YOUR_NGROK_URL/prompts/new) and upload an image together with a title, you will now trigger a prediction. When done, it will call back via the provided webhook. Because we set a `binding.irb` breakpoint there, the controller action stops in a REPL, and we can take a look around:

```ruby
> prediction.succeeded?
=> true
> prediction.output
=> ["https://replicate.delivery/SOME_SIGNED_PATH/out-0.png"]
```

As we can see, our first run of an image-to-image generation succeeded. The prediction model returns a URL to the created image, which we will want to store locally (they are deleted at Replicate periodically). We will take a closer look at this next time.

## Up Next: LiteDB Deep Dive

In this opening post, we introduced the LiteStack environment as an interesting alternative to host all of your Rails app's components on a single machine. We have furthermore set up an example app that talks to Replicate.com for AI image generation.

In the next part of this series, we will explore the first and central element of LiteStack — _LiteDB_ — in more depth. We will look at some traits that make it uniquely powerful, common pitfalls, limitations, and tradeoffs.

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](https://blog.appsignal.com/ruby-magic)!**