Skip to content

Commit 3eb29b0

Browse files
committed
wip: add dj/reseque adapter specs
1 parent 2fd7f50 commit 3eb29b0

6 files changed

Lines changed: 327 additions & 0 deletions

File tree

sentry-rails/Gemfile

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,33 @@ unless RUBY_PLATFORM.include?("java")
7676
end
7777
end
7878

79+
# delayed_job and resque are dev-only dependencies, used by the common
80+
# ActiveJob spec suite to verify the AJ tracing extension works against
81+
# the :delayed_job and :resque adapters independent of the dedicated
82+
# sentry-delayed_job / sentry-resque integrations.
83+
#
84+
# Both spec files rescue LoadError and skip cleanly on matrices that
85+
# don't bundle the gem, so the gating below only needs to keep
86+
# `bundle install` resolvable — it doesn't have to be exact.
87+
unless RUBY_PLATFORM.include?("java")
88+
# delayed_job is backed by ActiveRecord here (delayed_job_active_record),
89+
# reusing the dummy app's SQLite database. It supports every Ruby/Rails
90+
# combination in the matrix.
91+
gem "delayed_job"
92+
gem "delayed_job_active_record"
93+
94+
# resque has no in-memory test mode, so the spec drives it through
95+
# mock_redis instead of a live Redis (mirroring how the sidekiq context
96+
# uses Sidekiq's fake mode). resque 3 / resque-scheduler 5 / mock_redis
97+
# all require Ruby >= 3.0, so gate them on that — older matrices skip
98+
# the resque spec via its LoadError rescue.
99+
if ruby_version >= Gem::Version.new("3.0")
100+
gem "resque"
101+
gem "resque-scheduler", "~> 5.0"
102+
gem "mock_redis"
103+
end
104+
end
105+
79106
gem "sprockets-rails"
80107

81108
gem "benchmark-ips"
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# frozen_string_literal: true
2+
3+
require "spec_helper"
4+
5+
# delayed_job 4.2+ ships an ActiveJob adapter that inherits from
6+
# ActiveJob::QueueAdapters::AbstractAdapter, which only exists in Rails 7.2+.
7+
# On older Rails, instantiating the adapter raises NameError, so skip the
8+
# whole file. Bail out before loading delayed_job so old matrices don't trip
9+
# on the gem either.
10+
return if RAILS_VERSION < 7.2
11+
12+
# delayed_job is gated in the Gemfile by platform (skipped on JRuby).
13+
# Matrices that don't bundle it won't have it available — rescue LoadError
14+
# and skip the whole file so they don't blow up on the
15+
# `include_context "delayed_job adapter"` below.
16+
begin
17+
require "delayed_job"
18+
require "delayed_job_active_record"
19+
rescue LoadError
20+
return
21+
end
22+
23+
RSpec.describe "Sentry + ActiveJob on the delayed_job adapter", type: :job do
24+
include ActiveSupport::Testing::TimeHelpers
25+
include_context "active_job backend harness", adapter: :delayed_job
26+
include_context "delayed_job adapter"
27+
28+
it_behaves_like "a Sentry-instrumented ActiveJob backend"
29+
it_behaves_like "an ActiveJob backend that supports distributed tracing"
30+
end
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# frozen_string_literal: true
2+
3+
require "spec_helper"
4+
5+
# resque 3+ ships an ActiveJob adapter that inherits from
6+
# ActiveJob::QueueAdapters::AbstractAdapter, which only exists in Rails 7.2+.
7+
# On older Rails, instantiating the adapter raises NameError, so skip the
8+
# whole file. Bail out before loading resque so old matrices don't trip on
9+
# the gem either.
10+
return if RAILS_VERSION < 7.2
11+
12+
# resque (and mock_redis) are gated in the Gemfile by platform (skipped on
13+
# JRuby). Matrices that don't bundle them won't have them available —
14+
# rescue LoadError and skip the whole file so they don't blow up on the
15+
# `include_context "resque adapter"` below.
16+
begin
17+
require "mock_redis"
18+
require "resque"
19+
require "resque-scheduler"
20+
rescue LoadError
21+
return
22+
end
23+
24+
RSpec.describe "Sentry + ActiveJob on the resque adapter", type: :job do
25+
include ActiveSupport::Testing::TimeHelpers
26+
include_context "active_job backend harness", adapter: :resque
27+
include_context "resque adapter"
28+
29+
it_behaves_like "a Sentry-instrumented ActiveJob backend"
30+
it_behaves_like "an ActiveJob backend that supports distributed tracing"
31+
end
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
# frozen_string_literal: true
2+
3+
# Adapter context for the :delayed_job ActiveJob backend.
4+
#
5+
# Composes with "active_job backend harness" to drive delayed_job via its
6+
# ActiveRecord backend, reusing the dummy app's SQLite database (the
7+
# +delayed_jobs+ table lives in spec/dummy/test_rails_app/db/schema.rb).
8+
# No external service is required.
9+
#
10+
# This context deliberately does NOT require sentry-delayed_job. Loading
11+
# it would install delayed_job's own plugin (which emits its own
12+
# transactions / error reporting) and could register DelayedJobAdapter in
13+
# skippable_job_adapters, short-circuiting the AJ extension under test.
14+
15+
# delayed_job 4.2+ ships an ActiveJob adapter that inherits from
16+
# ActiveJob::QueueAdapters::AbstractAdapter, which only exists in Rails
17+
# 7.2+. Requiring delayed_job on older Rails drags that adapter in (the
18+
# railtie pulls it during app initialization), raising NameError, so don't
19+
# even load the gem there. The matching spec file applies the same
20+
# Rails-version guard and skips. RAILS_VERSION isn't defined yet at
21+
# support-load time, so read Rails.version directly.
22+
return if ::Rails.version.to_f < 7.2
23+
24+
begin
25+
require "delayed_job"
26+
require "delayed_job_active_record"
27+
rescue LoadError
28+
# delayed_job isn't bundled on this matrix (e.g. JRuby). The matching
29+
# spec file rescues the same LoadError and skips, so just don't define
30+
# the context here.
31+
return
32+
end
33+
34+
RSpec.shared_context "delayed_job adapter" do
35+
# Instantiated once. DelayedJobAdapter itself is stateless, but we mirror
36+
# the other adapter contexts (sidekiq, solid_queue) which memoize a
37+
# single adapter to avoid per-example churn.
38+
DELAYED_JOB_ADAPTER_FOR_TEST = ::ActiveJob::QueueAdapters::DelayedJobAdapter.new
39+
40+
def queue_adapter_for_test
41+
DELAYED_JOB_ADAPTER_FOR_TEST
42+
end
43+
44+
def reset_adapter(_adapter)
45+
::Delayed::Job.delete_all
46+
end
47+
48+
def drain(at: nil)
49+
# Drive each enqueued Delayed::Job record straight through
50+
# +JobWrapper#perform+ (== +ActiveJob::Base.execute+) rather than
51+
# +Delayed::Worker#work_off+. The worker would swallow the perform
52+
# exception (recording it on the record and rescheduling via
53+
# delayed_job's own attempts/max_attempts machinery), but the shared
54+
# examples rely on the worker exception propagating out of +drain+ and
55+
# on ActiveJob — not delayed_job — owning retry semantics.
56+
#
57+
# Each record is destroyed *before* it runs, the way a real worker
58+
# reserves a job: a given AJ attempt is one Delayed::Job record that
59+
# executes exactly once. ActiveJob's +retry_on+ re-enqueues a *fresh*
60+
# record, which the loop then picks up — so retries cascade within a
61+
# single drain, and a final, attempt-exhausting raise leaves nothing
62+
# runnable behind for a subsequent drain to re-run.
63+
run = lambda do
64+
loop do
65+
record = ::Delayed::Job
66+
.where("run_at IS NULL OR run_at <= ?", Time.current)
67+
.order(Arel.sql("run_at IS NULL DESC"), :run_at, :id)
68+
.first
69+
break unless record
70+
71+
payload = record.payload_object
72+
record.destroy
73+
payload.perform
74+
end
75+
end
76+
77+
# Only wrap in travel_to when the caller explicitly asks for a future
78+
# time (e.g. the scheduled_at example) — otherwise a nested travel_to
79+
# from a spec that already called +travel+ would raise.
80+
at ? travel_to(at, &run) : run.call
81+
end
82+
83+
def last_enqueued_payload
84+
record = ::Delayed::Job.order(:id).last
85+
return nil if record.nil?
86+
87+
# delayed_job stores the AJ-on-DelayedJob wrapper (carrying the
88+
# serialized job_data hash) YAML-encoded in the +handler+ column. The
89+
# deserialized +payload_object+ is the JobWrapper; +job_data+ is the
90+
# string-keyed ActiveJob payload (so callers can read
91+
# +payload["_sentry"]+, +payload["arguments"]+, etc.).
92+
record.payload_object.job_data
93+
end
94+
end
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+
# Adapter context for the :resque ActiveJob backend.
4+
#
5+
# Composes with "active_job backend harness" to drive resque entirely
6+
# in-memory via mock_redis — no live Redis required, mirroring how the
7+
# sidekiq context uses Sidekiq's fake mode. resque-scheduler is loaded so
8+
# the AJ adapter's +enqueue_at+ works; ActiveJob routes both scheduled
9+
# jobs (+wait:+) and +retry_on+ re-enqueues through +enqueue_at+, so the
10+
# delayed schedule has to be functional for the shared retry/scheduled
11+
# examples to pass.
12+
#
13+
# This context deliberately does NOT require sentry-resque. Loading it
14+
# would install resque's own Sentry integration (its server-side error
15+
# capture) and could register ResqueAdapter in skippable_job_adapters,
16+
# short-circuiting the AJ extension under test.
17+
18+
# resque 3+ ships an ActiveJob adapter that inherits from
19+
# ActiveJob::QueueAdapters::AbstractAdapter, which only exists in Rails
20+
# 7.2+. Requiring resque on older Rails drags that adapter in (it pulls it
21+
# during app initialization), raising NameError, so don't even load the gem
22+
# there. The matching spec file applies the same Rails-version guard and
23+
# skips. RAILS_VERSION isn't defined yet at support-load time, so read
24+
# Rails.version directly.
25+
return if ::Rails.version.to_f < 7.2
26+
27+
begin
28+
require "mock_redis"
29+
require "resque"
30+
require "resque-scheduler"
31+
rescue LoadError
32+
# resque/mock_redis isn't bundled on this matrix (e.g. JRuby). The
33+
# matching spec file rescues the same LoadError and skips, so just
34+
# don't define the context here.
35+
return
36+
end
37+
38+
RSpec.shared_context "resque adapter" do
39+
# ResqueAdapter is stateless; memoized once to mirror the other adapter
40+
# contexts (sidekiq, solid_queue).
41+
RESQUE_ADAPTER_FOR_TEST = ::ActiveJob::QueueAdapters::ResqueAdapter.new
42+
43+
def queue_adapter_for_test
44+
RESQUE_ADAPTER_FOR_TEST
45+
end
46+
47+
# Point resque at a fresh in-memory Redis for every example so no queue,
48+
# delayed-schedule, or +queues+ set state leaks between examples.
49+
# Resque memoizes its data store, so reassigning +Resque.redis+ rebuilds
50+
# it against the new MockRedis.
51+
def boot_adapter(_adapter)
52+
::Resque.redis = ::MockRedis.new
53+
::Resque.logger = ::Logger.new(nil)
54+
end
55+
56+
def drain(at: nil)
57+
# resque has no in-process "run everything" helper, so we reserve and
58+
# perform jobs ourselves. +Resque::Job#perform+ runs the job and
59+
# re-raises any exception (after its failure hooks), which preserves
60+
# the +expect { drain }.to raise_error(...)+ semantics the shared
61+
# examples rely on. ActiveJob — not resque — owns retry: a failing
62+
# +retry_on+ job re-enqueues itself through the adapter's +enqueue_at+
63+
# into resque-scheduler's delayed set, so each loop iteration first
64+
# promotes any now-due delayed jobs back onto their queues before
65+
# reserving. That cascades retries (and +wait: 0+ re-enqueues) to
66+
# completion within a single drain.
67+
run = lambda do
68+
loop do
69+
promote_due_delayed_jobs(Time.current)
70+
job = reserve_next_job
71+
break if job.nil?
72+
73+
job.perform
74+
end
75+
end
76+
77+
# Only wrap in travel_to when the caller explicitly asks for a future
78+
# time (e.g. the scheduled_at example) so that delayed jobs scheduled
79+
# in the future become due — otherwise a nested travel_to from a spec
80+
# that already called +travel+ would raise.
81+
at ? travel_to(at, &run) : run.call
82+
end
83+
84+
def last_enqueued_payload
85+
# The AJ-on-resque adapter wraps the serialized AJ payload as the sole
86+
# element of the resque job's +args+ (see ResqueAdapter#enqueue /
87+
# JobWrapper.perform). resque pushes to the tail and pops from the
88+
# head, so the most recently enqueued job sits at the end of its
89+
# queue. The shared example that reads this enqueues a single job, so
90+
# returning the tail of the first non-empty queue is sufficient.
91+
::Resque.queues.each do |queue|
92+
size = ::Resque.size(queue)
93+
next if size.zero?
94+
95+
item = ::Resque.peek(queue, size - 1)
96+
return item["args"].first if item
97+
end
98+
99+
nil
100+
end
101+
102+
private
103+
104+
# Move every delayed job whose scheduled timestamp is at or before
105+
# +up_to+ out of resque-scheduler's delayed set and back onto its
106+
# destination queue, ready to be reserved.
107+
def promote_due_delayed_jobs(up_to)
108+
while (timestamp = ::Resque.next_delayed_timestamp(up_to))
109+
while (item = ::Resque.next_item_for_timestamp(timestamp))
110+
klass = ::Resque.constantize(item["class"])
111+
queue = item["queue"] || ::Resque.queue_from_class(klass)
112+
::Resque.enqueue_to(queue, klass, *item["args"])
113+
end
114+
end
115+
end
116+
117+
# Reserve (pop) the next job from any non-empty queue, or nil when every
118+
# queue is empty.
119+
def reserve_next_job
120+
::Resque.queues.each do |queue|
121+
job = ::Resque.reserve(queue)
122+
return job if job
123+
end
124+
125+
nil
126+
end
127+
end

sentry-rails/spec/dummy/test_rails_app/db/schema.rb

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,24 @@
3333
t.integer "post_id"
3434
end
3535

36+
# Backs the :delayed_job ActiveJob adapter in the common ActiveJob spec
37+
# suite (see spec/active_job/support/delayed_job_adapter_context.rb).
38+
# Mirrors delayed_job_active_record's generator migration.
39+
create_table "delayed_jobs", force: :cascade do |t|
40+
t.integer "priority", default: 0, null: false
41+
t.integer "attempts", default: 0, null: false
42+
t.text "handler", null: false
43+
t.text "last_error"
44+
t.datetime "run_at"
45+
t.datetime "locked_at"
46+
t.datetime "failed_at"
47+
t.string "locked_by"
48+
t.string "queue"
49+
t.datetime "created_at"
50+
t.datetime "updated_at"
51+
t.index ["priority", "run_at"], name: "delayed_jobs_priority"
52+
end
53+
3654
create_table "posts", force: :cascade do |t|
3755
t.string "title"
3856
t.datetime "created_at", precision: 6, null: false

0 commit comments

Comments
 (0)