Skip to content

Commit 727ce14

Browse files
committed
feat(rails): initial support for Solid Queue
1 parent 82a9095 commit 727ce14

5 files changed

Lines changed: 274 additions & 1 deletion

File tree

sentry-rails/.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
/doc/
66
/pkg/
77
/spec/reports/
8-
/spec/dummy/test_rails_app/db*
8+
/spec/dummy/test_rails_app/**/*.sqlite3*
99
/tmp/
1010

1111
# rspec failure tracking

sentry-rails/Gemfile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,16 @@ gem "rails", "~> #{rails_version}"
3232

3333
if rails_version >= Gem::Version.new("8.1.0")
3434
gem "rspec-rails", "~> 8.0.0"
35+
gem "solid_queue"
3536
gem "sqlite3", "~> 2.1.1", platform: :ruby
3637
elsif rails_version >= Gem::Version.new("8.0.0")
3738
gem "rspec-rails", "~> 8.0.0"
39+
gem "solid_queue"
3840
gem "sqlite3", "~> 2.1.1", platform: :ruby
3941
elsif rails_version >= Gem::Version.new("7.1.0")
4042
gem "psych", "~> 4.0.0"
4143
gem "rspec-rails", "~> 7.0"
44+
gem "solid_queue"
4245
gem "sqlite3", "~> 1.7.3", platform: :ruby
4346
elsif rails_version >= Gem::Version.new("6.1.0")
4447
gem "rspec-rails", "~> 6.0"
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
# frozen_string_literal: true
2+
3+
require "spec_helper"
4+
5+
if RAILS_VERSION >= 7.1 && RUBY_VERSION >= "3.1"
6+
require "solid_queue"
7+
8+
RSpec.describe "Sentry + ActiveJob on SolidQueue", type: :job do
9+
include ActiveSupport::Testing::TimeHelpers
10+
include_context "active_job backend harness", adapter: :solid_queue
11+
12+
# Instantiated once. Each SolidQueueAdapter.new registers a
13+
# SolidQueue.on_worker_stop callback at class-load time (mutating
14+
# global SolidQueue state), so creating a fresh adapter per example
15+
# would accumulate callbacks across the run.
16+
SOLID_QUEUE_ADAPTER_FOR_TEST = ::ActiveJob::QueueAdapters::SolidQueueAdapter.new
17+
18+
def queue_adapter_for_test
19+
SOLID_QUEUE_ADAPTER_FOR_TEST
20+
end
21+
22+
WORKER_SHARD_COUNT = 4
23+
24+
def boot_adapter(_adapter)
25+
Sentry::Rails::Test::Application.load_queue_schema
26+
27+
install_worker_shards
28+
end
29+
30+
# Sets up `WORKER_SHARD_COUNT` independent SQLite databases as AR
31+
# shards alongside the primary test DB. Each worker thread spawned
32+
# by `worker_thread` claims its own shard, so concurrent perform_later
33+
# / drain calls from different threads never contend on the same
34+
# SQLite file (which would otherwise raise SQLite3::BusyException).
35+
def install_worker_shards
36+
base_dir = Sentry::Rails::Test::Application.root_path.join("db")
37+
worker_paths = (1..WORKER_SHARD_COUNT).map { |i| base_dir.join("queue_worker_#{i}.sqlite3") }
38+
39+
# Wipe any previous run's files so each spec starts fresh.
40+
worker_paths.each { |p| File.unlink(p) if File.exist?(p) }
41+
42+
primary_db = Sentry::Rails::Test::Application.db_path.to_s
43+
configs = { "primary" => { "adapter" => "sqlite3", "database" => primary_db, "timeout" => 5000 } }
44+
worker_paths.each_with_index do |path, i|
45+
configs["worker_#{i + 1}"] = { "adapter" => "sqlite3", "database" => path.to_s, "timeout" => 5000 }
46+
end
47+
48+
ActiveRecord::Base.configurations = { "test" => configs }
49+
50+
shards = { default: { writing: :primary } }
51+
WORKER_SHARD_COUNT.times { |i| shards[:"worker_#{i + 1}"] = { writing: :"worker_#{i + 1}" } }
52+
ActiveRecord::Base.connects_to(shards: shards)
53+
54+
# Load the queue schema into each worker shard so its tables exist.
55+
WORKER_SHARD_COUNT.times do |i|
56+
ActiveRecord::Base.connected_to(shard: :"worker_#{i + 1}") do
57+
load Sentry::Rails::Test::Application.queue_schema_file
58+
end
59+
end
60+
61+
@worker_shard_counter = 0
62+
@worker_shard_mutex = Mutex.new
63+
end
64+
65+
def next_worker_shard
66+
@worker_shard_mutex.synchronize do
67+
@worker_shard_counter = (@worker_shard_counter % WORKER_SHARD_COUNT) + 1
68+
:"worker_#{@worker_shard_counter}"
69+
end
70+
end
71+
72+
def worker_thread(&block)
73+
shard = next_worker_shard
74+
Thread.new do
75+
ActiveRecord::Base.connected_to(shard: shard, &block)
76+
end
77+
end
78+
79+
def reset_adapter(_adapter)
80+
[
81+
SolidQueue::ReadyExecution,
82+
SolidQueue::ClaimedExecution,
83+
SolidQueue::FailedExecution,
84+
SolidQueue::BlockedExecution,
85+
SolidQueue::ScheduledExecution,
86+
SolidQueue::RecurringExecution,
87+
SolidQueue::Process,
88+
SolidQueue::Job
89+
].each(&:delete_all)
90+
end
91+
92+
def drain(at: nil)
93+
process = SolidQueue::Process.register(
94+
kind: "Worker",
95+
pid: ::Process.pid,
96+
name: "spec-#{SecureRandom.hex(4)}"
97+
)
98+
99+
# Loop until both ready and scheduled tables are empty so that
100+
# retry_on cascades cleanly: a failing perform pushes the job into
101+
# SolidQueue::ScheduledExecution (via enqueue_at), which the next
102+
# iteration promotes to ReadyExecution and claims for execution.
103+
# A single dispatch+claim pass would only observe the first
104+
# attempt.
105+
run = lambda do
106+
loop do
107+
SolidQueue::ScheduledExecution.dispatch_next_batch(100)
108+
ready = SolidQueue::ReadyExecution.claim("*", 100, process.id)
109+
break if ready.empty? && SolidQueue::ScheduledExecution.none?
110+
ready.each(&:perform)
111+
end
112+
end
113+
114+
# Only wrap in travel_to when the caller explicitly asks for a future
115+
# time — otherwise nested travel_to (e.g. from a spec that already
116+
# called `travel`) raises.
117+
at ? travel_to(at, &run) : run.call
118+
end
119+
120+
def last_enqueued_payload
121+
SolidQueue::Job.order(:id).last&.arguments
122+
end
123+
124+
it_behaves_like "a Sentry-instrumented ActiveJob backend"
125+
it_behaves_like "an ActiveJob backend that supports distributed tracing"
126+
end
127+
end

sentry-rails/spec/dummy/test_rails_app/config/application.rb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ def self.schema_file
4545
@schema_file ||= root_path.join("db/schema.rb")
4646
end
4747

48+
def self.queue_schema_file
49+
@queue_schema_file ||= root_path.join("db/queue_schema.rb")
50+
end
51+
4852
def self.db_path
4953
@db_path ||= root_path.join("db", "db.sqlite3")
5054
end
@@ -77,6 +81,14 @@ def self.load_test_schema
7781
end
7882
end
7983

84+
def self.load_queue_schema
85+
@__queue_schema_loaded__ ||= begin
86+
load_test_schema
87+
require Test::Application.queue_schema_file
88+
true
89+
end
90+
end
91+
8092
# Configure method that sets up base configuration
8193
# This can be inherited and extended by subclasses
8294
def configure
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
# frozen_string_literal: true
2+
3+
ActiveRecord::Schema[7.1].define(version: 1) do
4+
create_table "solid_queue_blocked_executions", force: :cascade do |t|
5+
t.bigint "job_id", null: false
6+
t.string "queue_name", null: false
7+
t.integer "priority", default: 0, null: false
8+
t.string "concurrency_key", null: false
9+
t.datetime "expires_at", null: false
10+
t.datetime "created_at", null: false
11+
t.index [ "concurrency_key", "priority", "job_id" ], name: "index_solid_queue_blocked_executions_for_release"
12+
t.index [ "expires_at", "concurrency_key" ], name: "index_solid_queue_blocked_executions_for_maintenance"
13+
t.index [ "job_id" ], name: "index_solid_queue_blocked_executions_on_job_id", unique: true
14+
end
15+
16+
create_table "solid_queue_claimed_executions", force: :cascade do |t|
17+
t.bigint "job_id", null: false
18+
t.bigint "process_id"
19+
t.datetime "created_at", null: false
20+
t.index [ "job_id" ], name: "index_solid_queue_claimed_executions_on_job_id", unique: true
21+
t.index [ "process_id", "job_id" ], name: "index_solid_queue_claimed_executions_on_process_id_and_job_id"
22+
end
23+
24+
create_table "solid_queue_failed_executions", force: :cascade do |t|
25+
t.bigint "job_id", null: false
26+
t.text "error"
27+
t.datetime "created_at", null: false
28+
t.index [ "job_id" ], name: "index_solid_queue_failed_executions_on_job_id", unique: true
29+
end
30+
31+
create_table "solid_queue_jobs", force: :cascade do |t|
32+
t.string "queue_name", null: false
33+
t.string "class_name", null: false
34+
t.text "arguments"
35+
t.integer "priority", default: 0, null: false
36+
t.string "active_job_id"
37+
t.datetime "scheduled_at"
38+
t.datetime "finished_at"
39+
t.string "concurrency_key"
40+
t.datetime "created_at", null: false
41+
t.datetime "updated_at", null: false
42+
t.index [ "active_job_id" ], name: "index_solid_queue_jobs_on_active_job_id"
43+
t.index [ "class_name" ], name: "index_solid_queue_jobs_on_class_name"
44+
t.index [ "finished_at" ], name: "index_solid_queue_jobs_on_finished_at"
45+
t.index [ "queue_name", "finished_at" ], name: "index_solid_queue_jobs_for_filtering"
46+
t.index [ "scheduled_at", "finished_at" ], name: "index_solid_queue_jobs_for_alerting"
47+
end
48+
49+
create_table "solid_queue_pauses", force: :cascade do |t|
50+
t.string "queue_name", null: false
51+
t.datetime "created_at", null: false
52+
t.index [ "queue_name" ], name: "index_solid_queue_pauses_on_queue_name", unique: true
53+
end
54+
55+
create_table "solid_queue_processes", force: :cascade do |t|
56+
t.string "kind", null: false
57+
t.datetime "last_heartbeat_at", null: false
58+
t.bigint "supervisor_id"
59+
t.integer "pid", null: false
60+
t.string "hostname"
61+
t.text "metadata"
62+
t.datetime "created_at", null: false
63+
t.string "name", null: false
64+
t.index [ "last_heartbeat_at" ], name: "index_solid_queue_processes_on_last_heartbeat_at"
65+
t.index [ "name", "supervisor_id" ], name: "index_solid_queue_processes_on_name_and_supervisor_id", unique: true
66+
t.index [ "supervisor_id" ], name: "index_solid_queue_processes_on_supervisor_id"
67+
end
68+
69+
create_table "solid_queue_ready_executions", force: :cascade do |t|
70+
t.bigint "job_id", null: false
71+
t.string "queue_name", null: false
72+
t.integer "priority", default: 0, null: false
73+
t.datetime "created_at", null: false
74+
t.index [ "job_id" ], name: "index_solid_queue_ready_executions_on_job_id", unique: true
75+
t.index [ "priority", "job_id" ], name: "index_solid_queue_poll_all"
76+
t.index [ "queue_name", "priority", "job_id" ], name: "index_solid_queue_poll_by_queue"
77+
end
78+
79+
create_table "solid_queue_recurring_executions", force: :cascade do |t|
80+
t.bigint "job_id", null: false
81+
t.string "task_key", null: false
82+
t.datetime "run_at", null: false
83+
t.datetime "created_at", null: false
84+
t.index [ "job_id" ], name: "index_solid_queue_recurring_executions_on_job_id", unique: true
85+
t.index [ "task_key", "run_at" ], name: "index_solid_queue_recurring_executions_on_task_key_and_run_at", unique: true
86+
end
87+
88+
create_table "solid_queue_recurring_tasks", force: :cascade do |t|
89+
t.string "key", null: false
90+
t.string "schedule", null: false
91+
t.string "command", limit: 2048
92+
t.string "class_name"
93+
t.text "arguments"
94+
t.string "queue_name"
95+
t.integer "priority", default: 0
96+
t.boolean "static", default: true, null: false
97+
t.text "description"
98+
t.datetime "created_at", null: false
99+
t.datetime "updated_at", null: false
100+
t.index [ "key" ], name: "index_solid_queue_recurring_tasks_on_key", unique: true
101+
t.index [ "static" ], name: "index_solid_queue_recurring_tasks_on_static"
102+
end
103+
104+
create_table "solid_queue_scheduled_executions", force: :cascade do |t|
105+
t.bigint "job_id", null: false
106+
t.string "queue_name", null: false
107+
t.integer "priority", default: 0, null: false
108+
t.datetime "scheduled_at", null: false
109+
t.datetime "created_at", null: false
110+
t.index [ "job_id" ], name: "index_solid_queue_scheduled_executions_on_job_id", unique: true
111+
t.index [ "scheduled_at", "priority", "job_id" ], name: "index_solid_queue_dispatch_all"
112+
end
113+
114+
create_table "solid_queue_semaphores", force: :cascade do |t|
115+
t.string "key", null: false
116+
t.integer "value", default: 1, null: false
117+
t.datetime "expires_at", null: false
118+
t.datetime "created_at", null: false
119+
t.datetime "updated_at", null: false
120+
t.index [ "expires_at" ], name: "index_solid_queue_semaphores_on_expires_at"
121+
t.index [ "key", "value" ], name: "index_solid_queue_semaphores_on_key_and_value"
122+
t.index [ "key" ], name: "index_solid_queue_semaphores_on_key", unique: true
123+
end
124+
125+
add_foreign_key "solid_queue_blocked_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
126+
add_foreign_key "solid_queue_claimed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
127+
add_foreign_key "solid_queue_failed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
128+
add_foreign_key "solid_queue_ready_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
129+
add_foreign_key "solid_queue_recurring_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
130+
add_foreign_key "solid_queue_scheduled_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
131+
end

0 commit comments

Comments
 (0)