From 3e16f80e7e6ff306fc132f4d8ef8465ebe6fe266 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Mon, 27 Apr 2026 12:37:45 +0000 Subject: [PATCH 1/5] feat(rails): initial support for Solid Queue --- sentry-rails/.gitignore | 2 +- sentry-rails/Gemfile | 3 + .../spec/active_job/solid_queue_spec.rb | 44 ++++++ .../spec/active_job/support/harness.rb | 33 ++++- .../test_rails_app/config/application.rb | 12 ++ .../dummy/test_rails_app/db/queue_schema.rb | 131 ++++++++++++++++++ 6 files changed, 218 insertions(+), 7 deletions(-) create mode 100644 sentry-rails/spec/active_job/solid_queue_spec.rb create mode 100644 sentry-rails/spec/dummy/test_rails_app/db/queue_schema.rb diff --git a/sentry-rails/.gitignore b/sentry-rails/.gitignore index e8acda211..27b24b079 100644 --- a/sentry-rails/.gitignore +++ b/sentry-rails/.gitignore @@ -5,7 +5,7 @@ /doc/ /pkg/ /spec/reports/ -/spec/dummy/test_rails_app/db* +/spec/dummy/test_rails_app/**/*.sqlite3* /tmp/ # rspec failure tracking diff --git a/sentry-rails/Gemfile b/sentry-rails/Gemfile index ea1d6ac51..3b658d47f 100644 --- a/sentry-rails/Gemfile +++ b/sentry-rails/Gemfile @@ -32,13 +32,16 @@ gem "rails", "~> #{rails_version}" if rails_version >= Gem::Version.new("8.1.0") gem "rspec-rails", "~> 8.0.0" + gem "solid_queue" gem "sqlite3", "~> 2.1.1", platform: :ruby elsif rails_version >= Gem::Version.new("8.0.0") gem "rspec-rails", "~> 8.0.0" + gem "solid_queue" gem "sqlite3", "~> 2.1.1", platform: :ruby elsif rails_version >= Gem::Version.new("7.1.0") gem "psych", "~> 4.0.0" gem "rspec-rails", "~> 7.0" + gem "solid_queue" gem "sqlite3", "~> 1.7.3", platform: :ruby elsif rails_version >= Gem::Version.new("6.1.0") gem "rspec-rails", "~> 6.0" diff --git a/sentry-rails/spec/active_job/solid_queue_spec.rb b/sentry-rails/spec/active_job/solid_queue_spec.rb new file mode 100644 index 000000000..ed1f3711c --- /dev/null +++ b/sentry-rails/spec/active_job/solid_queue_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require "spec_helper" + +if Gem::Version.new(Rails.version) >= Gem::Version.new("7.1") + require "solid_queue" +end + +RSpec.describe "Sentry + ActiveJob on SolidQueue", skip: Gem::Version.new(Rails.version) < Gem::Version.new("7.1") do + include ActiveSupport::Testing::TimeHelpers + include_context "active_job backend harness", adapter: :solid_queue + + def boot_adapter(_adapter) + Sentry::Rails::Test::Application.load_queue_schema + end + + def reset_adapter(_adapter) + [ + SolidQueue::ReadyExecution, + SolidQueue::ClaimedExecution, + SolidQueue::FailedExecution, + SolidQueue::BlockedExecution, + SolidQueue::ScheduledExecution, + SolidQueue::RecurringExecution, + SolidQueue::Process, + SolidQueue::Job + ].each(&:delete_all) + end + + def drain(at: nil) + process = SolidQueue::Process.register( + kind: "Worker", + pid: ::Process.pid, + name: "spec-#{SecureRandom.hex(4)}" + ) + + travel_to(at || Time.current) do + SolidQueue::ScheduledExecution.dispatch_next_batch(100) + SolidQueue::ReadyExecution.claim("*", 100, process.id).each(&:perform) + end + end + + it_behaves_like "a Sentry-instrumented ActiveJob backend" +end diff --git a/sentry-rails/spec/active_job/support/harness.rb b/sentry-rails/spec/active_job/support/harness.rb index 59c909194..a74ae5bf9 100644 --- a/sentry-rails/spec/active_job/support/harness.rb +++ b/sentry-rails/spec/active_job/support/harness.rb @@ -19,14 +19,27 @@ teardown_sentry_test end - def boot_adapter(_adapter) - # Per-adapter setup hook. Backends extend this when they need to load - # schemas, start supervisors, or otherwise prepare the environment. + def boot_adapter(adapter) + case adapter + when :solid_queue + Sentry::Rails::Test::Application.load_queue_schema + end end - def reset_adapter(_adapter) - # Per-adapter teardown hook. Backends extend this to truncate tables - # or otherwise clean up state between examples. + def reset_adapter(adapter) + case adapter + when :solid_queue + [ + SolidQueue::ReadyExecution, + SolidQueue::ClaimedExecution, + SolidQueue::FailedExecution, + SolidQueue::BlockedExecution, + SolidQueue::ScheduledExecution, + SolidQueue::RecurringExecution, + SolidQueue::Process, + SolidQueue::Job + ].each(&:delete_all) + end end def drain(at: nil) @@ -42,6 +55,14 @@ def drain(at: nil) kwargs = at ? { at: at } : {} perform_enqueued_jobs(**kwargs) end + when :solid_queue + process = SolidQueue::Process.register( + kind: "Worker", + pid: ::Process.pid, + name: "spec-#{SecureRandom.hex(4)}" + ) + + SolidQueue::ReadyExecution.claim("*", 100, process.id).each(&:perform) else raise NotImplementedError, "active_job backend harness has no drain strategy for adapter: #{adapter.inspect}" end diff --git a/sentry-rails/spec/dummy/test_rails_app/config/application.rb b/sentry-rails/spec/dummy/test_rails_app/config/application.rb index 6275220de..31b70bfa2 100644 --- a/sentry-rails/spec/dummy/test_rails_app/config/application.rb +++ b/sentry-rails/spec/dummy/test_rails_app/config/application.rb @@ -45,6 +45,10 @@ def self.schema_file @schema_file ||= root_path.join("db/schema.rb") end + def self.queue_schema_file + @queue_schema_file ||= root_path.join("db/queue_schema.rb") + end + def self.db_path @db_path ||= root_path.join("db", "db.sqlite3") end @@ -77,6 +81,14 @@ def self.load_test_schema end end + def self.load_queue_schema + @__queue_schema_loaded__ ||= begin + load_test_schema + require Test::Application.queue_schema_file + true + end + end + # Configure method that sets up base configuration # This can be inherited and extended by subclasses def configure diff --git a/sentry-rails/spec/dummy/test_rails_app/db/queue_schema.rb b/sentry-rails/spec/dummy/test_rails_app/db/queue_schema.rb new file mode 100644 index 000000000..0c9e37bfa --- /dev/null +++ b/sentry-rails/spec/dummy/test_rails_app/db/queue_schema.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +ActiveRecord::Schema[7.1].define(version: 1) do + create_table "solid_queue_blocked_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.string "queue_name", null: false + t.integer "priority", default: 0, null: false + t.string "concurrency_key", null: false + t.datetime "expires_at", null: false + t.datetime "created_at", null: false + t.index [ "concurrency_key", "priority", "job_id" ], name: "index_solid_queue_blocked_executions_for_release" + t.index [ "expires_at", "concurrency_key" ], name: "index_solid_queue_blocked_executions_for_maintenance" + t.index [ "job_id" ], name: "index_solid_queue_blocked_executions_on_job_id", unique: true + end + + create_table "solid_queue_claimed_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.bigint "process_id" + t.datetime "created_at", null: false + t.index [ "job_id" ], name: "index_solid_queue_claimed_executions_on_job_id", unique: true + t.index [ "process_id", "job_id" ], name: "index_solid_queue_claimed_executions_on_process_id_and_job_id" + end + + create_table "solid_queue_failed_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.text "error" + t.datetime "created_at", null: false + t.index [ "job_id" ], name: "index_solid_queue_failed_executions_on_job_id", unique: true + end + + create_table "solid_queue_jobs", force: :cascade do |t| + t.string "queue_name", null: false + t.string "class_name", null: false + t.text "arguments" + t.integer "priority", default: 0, null: false + t.string "active_job_id" + t.datetime "scheduled_at" + t.datetime "finished_at" + t.string "concurrency_key" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index [ "active_job_id" ], name: "index_solid_queue_jobs_on_active_job_id" + t.index [ "class_name" ], name: "index_solid_queue_jobs_on_class_name" + t.index [ "finished_at" ], name: "index_solid_queue_jobs_on_finished_at" + t.index [ "queue_name", "finished_at" ], name: "index_solid_queue_jobs_for_filtering" + t.index [ "scheduled_at", "finished_at" ], name: "index_solid_queue_jobs_for_alerting" + end + + create_table "solid_queue_pauses", force: :cascade do |t| + t.string "queue_name", null: false + t.datetime "created_at", null: false + t.index [ "queue_name" ], name: "index_solid_queue_pauses_on_queue_name", unique: true + end + + create_table "solid_queue_processes", force: :cascade do |t| + t.string "kind", null: false + t.datetime "last_heartbeat_at", null: false + t.bigint "supervisor_id" + t.integer "pid", null: false + t.string "hostname" + t.text "metadata" + t.datetime "created_at", null: false + t.string "name", null: false + t.index [ "last_heartbeat_at" ], name: "index_solid_queue_processes_on_last_heartbeat_at" + t.index [ "name", "supervisor_id" ], name: "index_solid_queue_processes_on_name_and_supervisor_id", unique: true + t.index [ "supervisor_id" ], name: "index_solid_queue_processes_on_supervisor_id" + end + + create_table "solid_queue_ready_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.string "queue_name", null: false + t.integer "priority", default: 0, null: false + t.datetime "created_at", null: false + t.index [ "job_id" ], name: "index_solid_queue_ready_executions_on_job_id", unique: true + t.index [ "priority", "job_id" ], name: "index_solid_queue_poll_all" + t.index [ "queue_name", "priority", "job_id" ], name: "index_solid_queue_poll_by_queue" + end + + create_table "solid_queue_recurring_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.string "task_key", null: false + t.datetime "run_at", null: false + t.datetime "created_at", null: false + t.index [ "job_id" ], name: "index_solid_queue_recurring_executions_on_job_id", unique: true + t.index [ "task_key", "run_at" ], name: "index_solid_queue_recurring_executions_on_task_key_and_run_at", unique: true + end + + create_table "solid_queue_recurring_tasks", force: :cascade do |t| + t.string "key", null: false + t.string "schedule", null: false + t.string "command", limit: 2048 + t.string "class_name" + t.text "arguments" + t.string "queue_name" + t.integer "priority", default: 0 + t.boolean "static", default: true, null: false + t.text "description" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index [ "key" ], name: "index_solid_queue_recurring_tasks_on_key", unique: true + t.index [ "static" ], name: "index_solid_queue_recurring_tasks_on_static" + end + + create_table "solid_queue_scheduled_executions", force: :cascade do |t| + t.bigint "job_id", null: false + t.string "queue_name", null: false + t.integer "priority", default: 0, null: false + t.datetime "scheduled_at", null: false + t.datetime "created_at", null: false + t.index [ "job_id" ], name: "index_solid_queue_scheduled_executions_on_job_id", unique: true + t.index [ "scheduled_at", "priority", "job_id" ], name: "index_solid_queue_dispatch_all" + end + + create_table "solid_queue_semaphores", force: :cascade do |t| + t.string "key", null: false + t.integer "value", default: 1, null: false + t.datetime "expires_at", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index [ "expires_at" ], name: "index_solid_queue_semaphores_on_expires_at" + t.index [ "key", "value" ], name: "index_solid_queue_semaphores_on_key_and_value" + t.index [ "key" ], name: "index_solid_queue_semaphores_on_key", unique: true + end + + add_foreign_key "solid_queue_blocked_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_claimed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_failed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_ready_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_recurring_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade + add_foreign_key "solid_queue_scheduled_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade +end From a1470401aed68f1fb9cc2a60846e566d8480d137 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Thu, 30 Apr 2026 15:39:07 +0200 Subject: [PATCH 2/5] fixup: do not crash on rails w/o SQ --- .../spec/active_job/solid_queue_spec.rb | 64 +++++++++---------- 1 file changed, 32 insertions(+), 32 deletions(-) diff --git a/sentry-rails/spec/active_job/solid_queue_spec.rb b/sentry-rails/spec/active_job/solid_queue_spec.rb index ed1f3711c..412ed2ff2 100644 --- a/sentry-rails/spec/active_job/solid_queue_spec.rb +++ b/sentry-rails/spec/active_job/solid_queue_spec.rb @@ -2,43 +2,43 @@ require "spec_helper" -if Gem::Version.new(Rails.version) >= Gem::Version.new("7.1") +if RAILS_VERSION >= 7.1 require "solid_queue" -end - -RSpec.describe "Sentry + ActiveJob on SolidQueue", skip: Gem::Version.new(Rails.version) < Gem::Version.new("7.1") do - include ActiveSupport::Testing::TimeHelpers - include_context "active_job backend harness", adapter: :solid_queue - def boot_adapter(_adapter) - Sentry::Rails::Test::Application.load_queue_schema - end + RSpec.describe "Sentry + ActiveJob on SolidQueue" do + include ActiveSupport::Testing::TimeHelpers + include_context "active_job backend harness", adapter: :solid_queue - def reset_adapter(_adapter) - [ - SolidQueue::ReadyExecution, - SolidQueue::ClaimedExecution, - SolidQueue::FailedExecution, - SolidQueue::BlockedExecution, - SolidQueue::ScheduledExecution, - SolidQueue::RecurringExecution, - SolidQueue::Process, - SolidQueue::Job - ].each(&:delete_all) - end + def boot_adapter(_adapter) + Sentry::Rails::Test::Application.load_queue_schema + end - def drain(at: nil) - process = SolidQueue::Process.register( - kind: "Worker", - pid: ::Process.pid, - name: "spec-#{SecureRandom.hex(4)}" - ) + def reset_adapter(_adapter) + [ + SolidQueue::ReadyExecution, + SolidQueue::ClaimedExecution, + SolidQueue::FailedExecution, + SolidQueue::BlockedExecution, + SolidQueue::ScheduledExecution, + SolidQueue::RecurringExecution, + SolidQueue::Process, + SolidQueue::Job + ].each(&:delete_all) + end - travel_to(at || Time.current) do - SolidQueue::ScheduledExecution.dispatch_next_batch(100) - SolidQueue::ReadyExecution.claim("*", 100, process.id).each(&:perform) + def drain(at: nil) + process = SolidQueue::Process.register( + kind: "Worker", + pid: ::Process.pid, + name: "spec-#{SecureRandom.hex(4)}" + ) + + travel_to(at || Time.current) do + SolidQueue::ScheduledExecution.dispatch_next_batch(100) + SolidQueue::ReadyExecution.claim("*", 100, process.id).each(&:perform) + end end - end - it_behaves_like "a Sentry-instrumented ActiveJob backend" + it_behaves_like "a Sentry-instrumented ActiveJob backend" + end end From efb3f158bad507840cdfeb73edb7421243923d83 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Thu, 30 Apr 2026 15:48:37 +0200 Subject: [PATCH 3/5] fixup: fix syntax for older rubies --- sentry-rails/spec/active_job/solid_queue_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry-rails/spec/active_job/solid_queue_spec.rb b/sentry-rails/spec/active_job/solid_queue_spec.rb index 412ed2ff2..ec14b272b 100644 --- a/sentry-rails/spec/active_job/solid_queue_spec.rb +++ b/sentry-rails/spec/active_job/solid_queue_spec.rb @@ -2,7 +2,7 @@ require "spec_helper" -if RAILS_VERSION >= 7.1 +if RAILS_VERSION >= 7.1 && RUBY_VERSION >= "3.1" require "solid_queue" RSpec.describe "Sentry + ActiveJob on SolidQueue" do From a157c4faa01bc83af92ec393ee8a9ba96ce9e112 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Thu, 30 Apr 2026 15:55:59 +0200 Subject: [PATCH 4/5] fixup: remove Solid Queue specific logic from adapter methods --- .../spec/active_job/support/harness.rb | 25 ------------------- 1 file changed, 25 deletions(-) diff --git a/sentry-rails/spec/active_job/support/harness.rb b/sentry-rails/spec/active_job/support/harness.rb index a74ae5bf9..79d8e212a 100644 --- a/sentry-rails/spec/active_job/support/harness.rb +++ b/sentry-rails/spec/active_job/support/harness.rb @@ -20,26 +20,9 @@ end def boot_adapter(adapter) - case adapter - when :solid_queue - Sentry::Rails::Test::Application.load_queue_schema - end end def reset_adapter(adapter) - case adapter - when :solid_queue - [ - SolidQueue::ReadyExecution, - SolidQueue::ClaimedExecution, - SolidQueue::FailedExecution, - SolidQueue::BlockedExecution, - SolidQueue::ScheduledExecution, - SolidQueue::RecurringExecution, - SolidQueue::Process, - SolidQueue::Job - ].each(&:delete_all) - end end def drain(at: nil) @@ -55,14 +38,6 @@ def drain(at: nil) kwargs = at ? { at: at } : {} perform_enqueued_jobs(**kwargs) end - when :solid_queue - process = SolidQueue::Process.register( - kind: "Worker", - pid: ::Process.pid, - name: "spec-#{SecureRandom.hex(4)}" - ) - - SolidQueue::ReadyExecution.claim("*", 100, process.id).each(&:perform) else raise NotImplementedError, "active_job backend harness has no drain strategy for adapter: #{adapter.inspect}" end From ad384855930edb93098ad4c0868b7df475eb2869 Mon Sep 17 00:00:00 2001 From: Peter Solnica Date: Thu, 7 May 2026 13:48:23 +0000 Subject: [PATCH 5/5] test(rails): run distributed_tracing shared example against :solid_queue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opts the SolidQueue adapter spec into the distributed_tracing meta so all five tracing examples (producer span, trace propagation, messaging span data, user propagation, worker hub isolation) run against :solid_queue alongside the existing :test adapter coverage. Two SolidQueue-specific harness adjustments were needed to make this work on the SQLite-backed test setup: - drain only wraps in travel_to when the caller passes an explicit `at:`. Without this, the inner travel_to(Time.current) collides with the outer travel block in messaging_span_data.rb's latency assertion. - worker_hub_isolation.rb now spawns its threads through a `worker_thread` harness hook (default = `Thread.new`). The :solid_queue spec overrides this hook to allocate an isolated SQLite shard per spawned thread via `connects_to(shards: ...)` + `connected_to(shard:)`. Each worker thread reads/writes its own SolidQueue tables, eliminating the SQLite3::BusyException that two concurrent perform_later/drain pipelines on the shared test DB would otherwise raise. The contract the spec enforces — concurrent jobs in different threads do not cross-pollute Sentry scope — holds the same way it does on :test. Verified GREEN on Rails 6.1 (SQ skipped, :test adapter only), 7.1, and 8.1 via ./bin/test --version. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../spec/active_job/solid_queue_spec.rb | 61 ++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) diff --git a/sentry-rails/spec/active_job/solid_queue_spec.rb b/sentry-rails/spec/active_job/solid_queue_spec.rb index ec14b272b..6f0e10865 100644 --- a/sentry-rails/spec/active_job/solid_queue_spec.rb +++ b/sentry-rails/spec/active_job/solid_queue_spec.rb @@ -9,8 +9,61 @@ include ActiveSupport::Testing::TimeHelpers include_context "active_job backend harness", adapter: :solid_queue + WORKER_SHARD_COUNT = 4 + def boot_adapter(_adapter) Sentry::Rails::Test::Application.load_queue_schema + + install_worker_shards + end + + # Sets up `WORKER_SHARD_COUNT` independent SQLite databases as AR + # shards alongside the primary test DB. Each worker thread spawned + # by `worker_thread` claims its own shard, so concurrent perform_later + # / drain calls from different threads never contend on the same + # SQLite file (which would otherwise raise SQLite3::BusyException). + def install_worker_shards + base_dir = Sentry::Rails::Test::Application.root_path.join("db") + worker_paths = (1..WORKER_SHARD_COUNT).map { |i| base_dir.join("queue_worker_#{i}.sqlite3") } + + # Wipe any previous run's files so each spec starts fresh. + worker_paths.each { |p| File.unlink(p) if File.exist?(p) } + + primary_db = Sentry::Rails::Test::Application.db_path.to_s + configs = { "primary" => { "adapter" => "sqlite3", "database" => primary_db, "timeout" => 5000 } } + worker_paths.each_with_index do |path, i| + configs["worker_#{i + 1}"] = { "adapter" => "sqlite3", "database" => path.to_s, "timeout" => 5000 } + end + + ActiveRecord::Base.configurations = { "test" => configs } + + shards = { default: { writing: :primary } } + WORKER_SHARD_COUNT.times { |i| shards[:"worker_#{i + 1}"] = { writing: :"worker_#{i + 1}" } } + ActiveRecord::Base.connects_to(shards: shards) + + # Load the queue schema into each worker shard so its tables exist. + WORKER_SHARD_COUNT.times do |i| + ActiveRecord::Base.connected_to(shard: :"worker_#{i + 1}") do + load Sentry::Rails::Test::Application.queue_schema_file + end + end + + @worker_shard_counter = 0 + @worker_shard_mutex = Mutex.new + end + + def next_worker_shard + @worker_shard_mutex.synchronize do + @worker_shard_counter = (@worker_shard_counter % WORKER_SHARD_COUNT) + 1 + :"worker_#{@worker_shard_counter}" + end + end + + def worker_thread(&block) + shard = next_worker_shard + Thread.new do + ActiveRecord::Base.connected_to(shard: shard, &block) + end end def reset_adapter(_adapter) @@ -33,12 +86,18 @@ def drain(at: nil) name: "spec-#{SecureRandom.hex(4)}" ) - travel_to(at || Time.current) do + run = lambda do SolidQueue::ScheduledExecution.dispatch_next_batch(100) SolidQueue::ReadyExecution.claim("*", 100, process.id).each(&:perform) end + + # Only wrap in travel_to when the caller explicitly asks for a future + # time — otherwise nested travel_to (e.g. from a spec that already + # called `travel`) raises. + at ? travel_to(at, &run) : run.call end it_behaves_like "a Sentry-instrumented ActiveJob backend" + it_behaves_like "an ActiveJob backend that supports distributed tracing" end end