Skip to main content

Migrate a Sidekiq job queue to a Temporal Standalone Activity

View Markdown

Sidekiq is a background job framework for Ruby that runs work by pushing jobs through Redis to a pool of worker threads. Most Sidekiq jobs are self-contained: send one email, resize one image, call one API. For that kind of single-step job, you want durable execution and automatic retries without having to stand up an orchestration layer around each job.

Standalone Activities fit that need. A Standalone Activity is an Activity you start directly from a Temporal Client, without wrapping it in a Workflow. You get Temporal's durability, retries, timeouts, and visibility for an individual unit of work, which maps almost one-to-one onto a Sidekiq job. Because there is no Workflow to run a single Activity, Standalone Activities also use fewer resources than orchestrating one Activity through a Workflow.

Note: Standalone Activities are in Public Preview and marked experimental in the Ruby SDK. The APIs shown here may change before the stable release.

In this guide, you will migrate a Sidekiq job to a Temporal Standalone Activity. You will convert the job into an Activity, run a Worker to process it, start it fire-and-forget in place of perform_async, retrieve a return value (something Sidekiq cannot do), migrate its retries to a Retry Policy, and inspect running Activities in place of the Sidekiq Web UI. By the end, you will have a working Temporal application that reproduces the behavior of your Sidekiq app with no Workflow code.

How Sidekiq concepts map to Standalone Activities

Before you start, it helps to know which Temporal building block replaces each Sidekiq concept. You will implement each row of this table in the steps that follow.

SidekiqTemporal Standalone ActivityPurpose
Job (include Sidekiq::Job, perform)Activity (Temporalio::Activity::Definition, execute)A single unit of work (I/O, API calls)
sidekiq processWorker (activities only, no Workflows)Process that executes your code
RedisTemporal ServiceDurably stores queue state and results
Job.perform_async(...)client.start_activity(...)Kick off work without waiting
(no result support)client.execute_activity(...) or handle.resultRetrieve the return value
sidekiq_options retry: NRetryPolicyAutomatic retries
Sidekiq Web UI / APIclient.list_activities / client.count_activitiesMonitor jobs

One difference stands out in that table: Sidekiq has no result backend, so a job cannot return a value to its caller. Temporal can, which is why the execute_activity row has no Sidekiq equivalent. You will see this pay off in Step 6.

Prerequisites

Before you begin, you will need the following:

  • Ruby 3.2 or higher installed on your machine (3.3 or higher is recommended, since the Worker can then run on fibers).
  • The temporalio gem (the Temporal Ruby SDK). Standalone Activities are a recent addition, so install the latest version (added in Step 2).
  • Temporal Server v1.31.0 or higher (bundled with a recent Temporal CLI's development server).
  • The Temporal CLI, version 1.7.0 or higher (installed in Step 2).
  • An existing Sidekiq job you want to migrate, or the sample job shown in Step 4 if you are following along from scratch.

Step 1: Set up your project directory

In this step, you will create a small project layout. Because Standalone Activities need no Workflow code, the structure is flat: one file for the Activity, one Worker, and one script for each way of invoking the Activity.

Create a new project directory and move into it:

mkdir temporal-standalone && cd temporal-standalone

Your project will grow into the following files as you work through the tutorial:

temporal-standalone/
├── my_activity.rb # The Activity (your former Sidekiq job)
├── worker.rb # Runs the Worker
├── execute_activity.rb # Runs the Activity and waits for the result
├── start_activity.rb # Starts the Activity without waiting
└── inspect_activities.rb # Lists and counts Activities

With the directory in place, you can install the tools you need.

Step 2: Install the Temporal SDK and CLI

In this step, you will install the Ruby SDK your code depends on and the Temporal CLI you will use to run a local server.

Install the Temporal Ruby SDK with gem:

gem install temporalio

If you use Bundler, add it to your Gemfile instead and run bundle install:

# Gemfile
gem "temporalio"

Next, install the Temporal CLI (version 1.7.0 or higher, which bundles a compatible development server). On macOS or Linux with Homebrew, run:

brew install temporal

If you aren't using Homebrew, download the binary for your platform from the Temporal CLI install guide and add it to your PATH.

Verify the CLI version, since Standalone Activities require 1.7.0 or higher:

temporal --version

Confirm the printed version is at least 1.7.0. With the tools installed, you can start a local Temporal Service.

Step 3: Start the Temporal Development Server

In Sidekiq, work flows through Redis. In Temporal, work flows through the Temporal Service, which also stores each Activity's durable state. In this step, you will start a local development server that stands in for Redis.

Start the development server:

temporal server start-dev

You will see output confirming the server is running, including two addresses:

Server: localhost:7233
UI: http://localhost:8233

Your application code will connect to localhost:7233. The Web UI at http://localhost:8233 lets you inspect Activities and their results, much like the Sidekiq Web UI; Standalone Activities appear under their own item in the navigation. Leave this process running and open a new terminal for the remaining steps.

Step 4: Convert a Sidekiq job into an Activity

In this step, you will take a Sidekiq job and rewrite it as a Temporal Activity. The work inside (network calls, database writes, file I/O) stays the same. You write a Standalone Activity exactly the way you would write any Temporal Activity; nothing about the class marks it as "standalone." What makes it standalone is how you invoke it, which you will do in Step 6.

Consider a typical Sidekiq job that sends a welcome email. Its welcome_email_job.rb might look like this:

# Sidekiq version — welcome_email_job.rb
class WelcomeEmailJob
include Sidekiq::Job
sidekiq_options queue: "default", retry: 5

def perform(user_id)
user = User.find(user_id)
UserMailer.welcome(user).deliver_now
end
end

Create my_activity.rb and add the Temporal equivalent:

# my_activity.rb
require "temporalio/activity"

# A tiny stand-in for your user model.
User = Struct.new(:user_id, :email)

class SendWelcomeEmail < Temporalio::Activity::Definition
# Sidekiq jobs take simple, JSON-safe arguments. Here we accept a single
# hash so you can add fields later without breaking callers. Use string
# keys so values round-trip cleanly through Temporal's JSON conversion.
def execute(input)
user = get_user(input["user_id"])
deliver_email(user.email, "Welcome!")
"sent to #{user.email}"
end

private

# --- Mock helpers -------------------------------------------------------
# Replace these with your real user lookup and mailer when you adapt this.
def get_user(user_id)
User.new(user_id, "user#{user_id}@example.com")
end

def deliver_email(address, subject)
puts "Delivering '#{subject}' to #{address}"
end
# ------------------------------------------------------------------------
end

Two changes are worth noting. First, the retry configuration is gone: you no longer set retry: 5, because Temporal retries a failed Activity automatically. You will configure how it retries in Step 8. Second, the Activity is a class that extends Temporalio::Activity::Definition and implements execute, in place of a class that includes Sidekiq::Job and implements perform.

The User struct and the two mock helpers let this file run end to end without a database or mailer. When you migrate your own job, swap get_user and deliver_email for your real implementations; the Activity itself does not change. Now you need a Worker to run it.

Step 5: Run a Worker to process the Activity

Just as the sidekiq process pulls jobs from Redis, a Temporal Worker polls a Task Queue for work. A Worker for Standalone Activities is an ordinary Temporal Worker with your Activities registered and no Workflows. In this step, you will create and start that Worker.

Create worker.rb:

# worker.rb
require "temporalio/client"
require "temporalio/worker"
require_relative "my_activity"

client = Temporalio::Client.connect("localhost:7233", "default")

worker = Temporalio::Worker.new(
client: client,
task_queue: "email-tasks",
activities: [SendWelcomeEmail]
)

puts "Worker running..."
worker.run(shutdown_signals: ["SIGINT"])

The task_queue name is the routing key that ties your Worker and your invocation scripts together, similar to a Sidekiq queue name. By default the Ruby SDK runs Activities in a thread pool, so your synchronous execute method works without extra configuration; the pool size controls how many Activities run at once, much like Sidekiq's concurrency setting.

Start the Worker:

ruby worker.rb

The Worker begins polling the email-tasks Task Queue and waits for work. Leave it running and open another terminal to invoke it. You can stop it later with Ctrl+C.

Step 6: Execute an Activity and get its result

Sidekiq jobs are fire-and-forget: perform_async enqueues a job but never returns its result, because Sidekiq has no result backend. Temporal can return a result directly. In this step, you will run your Activity and print what it returns — a capability you did not have with Sidekiq.

The client call is execute_activity, which durably enqueues the Activity, waits for a Worker to run it, and returns the result. Create execute_activity.rb:

# execute_activity.rb
require "temporalio/client"
require_relative "my_activity"

client = Temporalio::Client.connect("localhost:7233", "default")

result = client.execute_activity(
SendWelcomeEmail,
{ "user_id" => 42 },
id: "welcome-email-42",
task_queue: "email-tasks",
start_to_close_timeout: 30
)

puts "Result: #{result}"

Run it:

ruby execute_activity.rb

You will see the Activity's return value:

Result: sent to user42@example.com

A couple of details are worth calling out. The id you provide is a business identifier you choose (an order number, a user identifier); Temporal uses it, along with the Id reuse and conflict policies, to guarantee the same Activity is not started twice, which is a built-in form of deduplication. Every Activity also requires a timeout — start_to_close_timeout (in seconds) caps how long one attempt may run, a safety net Sidekiq leaves to you.

Step 7: Start an Activity in place of perform_async

perform_async is Sidekiq's core call: it enqueues a job and returns immediately. The Temporal equivalent is start_activity, which durably enqueues the Activity and hands back a handle you can use later. In this step, you will start an Activity without blocking on it. (Note that the activity ID differs from Step 6 to avoid the deduplication effect described there.)

Create start_activity.rb:

# start_activity.rb
require "temporalio/client"
require_relative "my_activity"

client = Temporalio::Client.connect("localhost:7233", "default")

handle = client.start_activity(
SendWelcomeEmail,
{ "user_id" => 42 },
id: "welcome-email-async",
task_queue: "email-tasks",
start_to_close_timeout: 30
)

puts "Activity started"

# Later, when you actually need the value, block on the handle:
puts "Result: #{handle.result}"

Run it:

ruby start_activity.rb

start_activity corresponds to perform_async and returns a handle immediately. Unlike Sidekiq, you can then call handle.result to wait for the outcome if you ever need it. If you need to reconnect to an Activity from a different process — for example, a web request started it and a later request checks on it — recreate the handle from the Activity's Id and Run Id (the Run Id is available on the handle returned by start_activity):

handle = client.activity_handle("welcome-email-async", activity_run_id: run_id)

To delay execution the way Sidekiq's perform_in or perform_at does, pass start_delay: (in seconds) to start_activity.

Step 8: Migrate job retries to a Retry Policy

In Sidekiq, you cap retries with sidekiq_options retry: 5. With Temporal, retries are automatic and declarative. The default Retry Policy for Activities retries indefinitely with exponential backoff, which is sufficient in most cases and typically doesn't need adjusting.

If you want to replicate the Sidekiq behavior exactly, migrating means adding a limit back in. Note the counting difference: Sidekiq's retry: 5 means five retries after the first attempt, whereas Temporal's max_attempts counts total attempts. To match retry: 5, set max_attempts to 6.

Update execute_activity.rb to pass a retry_policy:

# execute_activity.rb (updated)
require "temporalio/client"
require "temporalio/retry_policy"
require_relative "my_activity"

client = Temporalio::Client.connect("localhost:7233", "default")

result = client.execute_activity(
SendWelcomeEmail,
{ "user_id" => 42 },
id: "welcome-email-42",
task_queue: "email-tasks",
start_to_close_timeout: 30,
retry_policy: Temporalio::RetryPolicy.new(
max_attempts: 6,
max_interval: 60,
non_retryable_error_types: ["InvalidUserError"]
)
)

puts "Result: #{result}"

Here, max_attempts: 6 matches Sidekiq's retry: 5, and max_interval (in seconds) caps the backoff between attempts. The non_retryable_error_types list names errors that should fail immediately without retrying — useful for permanent failures such as a missing record, where retrying cannot help. To raise such an error from the Activity, use Temporalio::Error::ApplicationError with non_retryable: true in my_activity.rb:

# my_activity.rb (excerpt)
require "temporalio/error"

def execute(input)
user = get_user(input["user_id"])
raise Temporalio::Error::ApplicationError.new(
"No such user", type: "InvalidUserError", non_retryable: true
) if user.nil?

deliver_email(user.email, "Welcome!")
"sent to #{user.email}"
end

Because the mock deliver_email in this tutorial never fails, the happy path completes on the first attempt. To watch a retry happen, make deliver_email raise an exception on its first call or two; Temporal will re-run the Activity automatically according to the policy above. The same retry_policy: argument works on start_activity as well.

Step 9: Inspect Activities in place of the Sidekiq Web UI

Sidekiq users reach for the Web UI or the Sidekiq API to see what is running. Temporal provides equivalent visibility directly through the client: you can list and count Standalone Activities that match a filter, the same way you would query Workflow Executions. In this step, you will write a small script to inspect your Activities.

Create inspect_activities.rb:

# inspect_activities.rb
require "temporalio/client"

client = Temporalio::Client.connect("localhost:7233", "default")

query = "TaskQueue = 'email-tasks'"

# List: like the Sidekiq Web UI's job list, but durable and queryable.
client.list_activities(query).each do |info|
puts "#{info.activity_id} | #{info.activity_type} | #{info.task_queue}"
end

# Count: total executions (running, completed, failed), not queued jobs.
count = client.count_activities(query).count
puts "Total activities: #{count}"

Run it:

ruby inspect_activities.rb

You will see one line per Activity execution, followed by a total count:

welcome-email-42 | SendWelcomeEmail | email-tasks
welcome-email-async | SendWelcomeEmail | email-tasks
Total activities: 2

The query uses the same List Filter syntax as Workflow visibility, so you can filter by attributes such as ActivityType and Status — for example, "ActivityType = 'SendWelcomeEmail' AND Status = 'Running'". Each info also exposes status, schedule_time, close_time, and execution_duration if you need them. These calls return only Standalone Activities; Activities running inside Workflows are excluded. The Temporal CLI offers the same views with temporal activity list and temporal activity count.

Step 10: When to use a Workflow

Standalone Activities replace the common case: a Sidekiq job that does one independent thing. They deliberately have no orchestration, so there is one situation they do not cover — multi-step pipelines.

If you coordinate several jobs — chaining them so one result feeds the next, fanning work out in parallel, or running a callback after a group finishes (the kind of thing Sidekiq Pro Batches or hand-rolled job chaining handle) — that coordination logic needs somewhere to live durably. A Standalone Activity cannot call another Activity or guarantee progress across several steps. For those pipelines, wrap your Activities in a Temporal Workflow, where sequencing is ordinary Ruby and parallelism uses the SDK's futures. See the Temporal Ruby documentation for building Workflows.

A rule of thumb: migrate a job to a Standalone Activity when it stands on its own, and to a Workflow when it coordinates other jobs. Most Sidekiq jobs are the former.

Conclusion

In this tutorial, you migrated a Sidekiq job to a Temporal Standalone Activity. You converted the job into an Activity, ran a Worker to execute it, started it fire-and-forget in place of perform_async, retrieved a return value that Sidekiq could never give you, replaced sidekiq_options retry: with a Retry Policy, and inspected your Activities in place of the Sidekiq Web UI — all without writing a single Workflow. Your jobs now survive Worker crashes, retry on well-defined policies, and remain queryable through the client and Web UI.

Because Standalone Activities are in Public Preview, review the Temporal Standalone Activity overview for the latest API details before relying on them in production. Useful next topics include: