postburner 1.0.0.pre.11 → 1.0.0.pre.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +983 -553
  3. data/app/concerns/postburner/commands.rb +1 -1
  4. data/app/concerns/postburner/execution.rb +11 -11
  5. data/app/concerns/postburner/insertion.rb +4 -1
  6. data/app/concerns/postburner/logging.rb +2 -2
  7. data/app/concerns/postburner/statistics.rb +1 -1
  8. data/app/models/postburner/job.rb +27 -4
  9. data/app/models/postburner/mailer.rb +1 -1
  10. data/app/models/postburner/schedule.rb +722 -0
  11. data/app/models/postburner/schedule_execution.rb +353 -0
  12. data/app/views/postburner/jobs/show.html.haml +3 -3
  13. data/lib/generators/postburner/install/install_generator.rb +1 -0
  14. data/lib/generators/postburner/install/templates/config/postburner.yml +15 -6
  15. data/lib/generators/postburner/install/templates/migrations/create_postburner_schedules.rb.erb +71 -0
  16. data/lib/postburner/active_job/adapter.rb +50 -19
  17. data/lib/postburner/active_job/payload.rb +5 -0
  18. data/lib/postburner/advisory_lock.rb +123 -0
  19. data/lib/postburner/configuration.rb +48 -7
  20. data/lib/postburner/connection.rb +7 -6
  21. data/lib/postburner/runner.rb +26 -3
  22. data/lib/postburner/scheduler.rb +427 -0
  23. data/lib/postburner/strategies/immediate_test_queue.rb +24 -7
  24. data/lib/postburner/strategies/nice_queue.rb +1 -1
  25. data/lib/postburner/strategies/null_queue.rb +2 -2
  26. data/lib/postburner/strategies/test_queue.rb +2 -2
  27. data/lib/postburner/time_helpers.rb +4 -2
  28. data/lib/postburner/tube.rb +9 -1
  29. data/lib/postburner/version.rb +1 -1
  30. data/lib/postburner/worker.rb +684 -0
  31. data/lib/postburner.rb +32 -13
  32. metadata +7 -3
  33. data/lib/postburner/workers/base.rb +0 -205
  34. data/lib/postburner/workers/worker.rb +0 -396
@@ -0,0 +1,427 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'postburner/advisory_lock'
4
+
5
+ module Postburner
6
+ # Lightweight scheduler that acts as a safety net for recurring job execution.
7
+ #
8
+ # Unlike traditional schedulers that poll for due jobs, Postburner uses **immediate
9
+ # enqueue** where executions are created and immediately queued to Beanstalkd's
10
+ # delayed queue. The scheduler acts as a **watchdog safety net** to ensure every
11
+ # schedule has a future execution queued.
12
+ #
13
+ # ## Architecture
14
+ #
15
+ # This class is NOT persisted to the database. It's instantiated on-the-fly by
16
+ # workers when they reserve a watchdog job from the scheduler tube. The watchdog
17
+ # is ephemeral data in Beanstalkd with this payload:
18
+ #
19
+ # { "scheduler": true, "interval": 300 }
20
+ #
21
+ # No dedicated scheduler process is needed - existing workers handle everything.
22
+ #
23
+ # ## Watchdog Pattern
24
+ #
25
+ # 1. Workers automatically watch the 'scheduler' tube
26
+ # 2. On reserve timeout, workers check if watchdog exists and create if missing
27
+ # 3. When worker reserves watchdog, it instantiates Postburner::Scheduler
28
+ # 4. Scheduler executes with PostgreSQL advisory lock for coordination
29
+ # 5. After completion, watchdog re-queues itself with delay for next interval
30
+ #
31
+ # ## Safety Net Functions
32
+ #
33
+ # The watchdog performs three safety net functions:
34
+ #
35
+ # 1. **Auto-bootstrap**: Creates first execution for schedules that haven't been started
36
+ # 2. **Future execution guarantee**: Ensures each schedule has a future execution queued
37
+ # 3. **Orphan cleanup**: Enqueues any pending executions that weren't properly queued
38
+ #
39
+ # Note: For Postburner::Job schedules, a before_attempt callback creates the next
40
+ # execution when the current job runs, providing immediate pickup without waiting
41
+ # for the watchdog. The watchdog is just the safety net.
42
+ #
43
+ # ## Configuration
44
+ #
45
+ # Add to config/postburner.yml:
46
+ #
47
+ # production:
48
+ # default_scheduler_interval: 300 # Check every 5 minutes (default)
49
+ # default_scheduler_priority: 100 # Watchdog priority (default)
50
+ #
51
+ # The interval primarily affects:
52
+ # - How quickly new schedules are auto-bootstrapped (if you don't call start!)
53
+ # - Recovery time if an execution somehow fails to enqueue
54
+ # - How often last_audit_at is updated for monitoring
55
+ #
56
+ # Since executions are enqueued immediately to Beanstalkd's delayed queue, the
57
+ # watchdog interval doesn't affect execution timing - only the safety net checks.
58
+ #
59
+ # ## Execution Flow
60
+ #
61
+ # 1. Worker reserves watchdog from scheduler tube
62
+ # 2. Instantiates Scheduler with interval from payload
63
+ # 3. Scheduler#perform acquires PostgreSQL advisory lock
64
+ # 4. Processes all enabled schedules:
65
+ # - Auto-bootstraps unstarted schedules (creates + enqueues first execution)
66
+ # - Ensures future execution exists (creates + enqueues if missing)
67
+ # - Enqueues any orphaned pending executions
68
+ # - Updates last_audit_at timestamp
69
+ # 5. Re-queues watchdog with delay for next interval
70
+ #
71
+ # ## Observability
72
+ #
73
+ # Monitor scheduler health via last_audit_at:
74
+ #
75
+ # stale = Postburner::Schedule.enabled
76
+ # .where('last_audit_at < ?', 15.minutes.ago)
77
+ # .or(Postburner::Schedule.where(last_audit_at: nil))
78
+ #
79
+ # @example Watchdog payload in Beanstalkd
80
+ # {
81
+ # "scheduler": true,
82
+ # "interval": 300
83
+ # }
84
+ #
85
+ # @example Manual watchdog creation (usually automatic)
86
+ # Postburner::Scheduler.enqueue_watchdog(interval: 300, priority: 100)
87
+ #
88
+ # @see Postburner::Schedule
89
+ # @see Postburner::ScheduleExecution
90
+ #
91
+ class Scheduler
92
+ SCHEDULER_TUBE_NAME = 'scheduler'
93
+ DEFAULT_INTERVAL = 300 # 5 minutes
94
+ DEFAULT_PRIORITY = 100 # Lower number = higher priority
95
+
96
+ attr_reader :interval, :logger
97
+
98
+ # Initialize a new scheduler instance.
99
+ #
100
+ # The scheduler is instantiated by workers when they reserve a watchdog job
101
+ # from the scheduler tube. It's not persisted - each run creates a new instance.
102
+ #
103
+ # @param interval [Integer] Seconds until next run (default: 300)
104
+ # @param logger [Logger, nil] Logger instance (default: Rails.logger or stdout)
105
+ #
106
+ # @example
107
+ # scheduler = Postburner::Scheduler.new(interval: 300)
108
+ # scheduler.perform
109
+ #
110
+ def initialize(interval: DEFAULT_INTERVAL, logger: nil)
111
+ @interval = interval
112
+ @logger = logger || (defined?(Rails) ? Rails.logger : Logger.new($stdout))
113
+ end
114
+
115
+ # Execute the scheduler.
116
+ #
117
+ # Processes all enabled schedules with PostgreSQL advisory lock coordination
118
+ # to prevent concurrent execution across multiple workers. After processing,
119
+ # automatically re-queues the watchdog for the next run.
120
+ #
121
+ # The scheduler performs two main functions:
122
+ # 1. Auto-bootstrap new schedules (create first execution if not started)
123
+ # 2. Ensure each schedule has a future execution queued
124
+ # 3. Enqueue any orphaned pending executions that weren't properly queued
125
+ #
126
+ # @return [void]
127
+ #
128
+ # @example Called by worker
129
+ # # Worker reserves watchdog job with payload:
130
+ # # { "scheduler" => true, "interval" => 300 }
131
+ # scheduler = Postburner::Scheduler.new(interval: 300)
132
+ # scheduler.perform
133
+ #
134
+ def perform
135
+ logger.info "[Postburner::Scheduler] Starting scheduler run"
136
+
137
+ # Use advisory lock to coordinate multiple workers
138
+ acquired = Postburner::AdvisoryLock.with_lock(AdvisoryLock::SCHEDULER_LOCK_KEY, blocking: false) do
139
+ process_all_schedules
140
+ true
141
+ end
142
+
143
+ if acquired
144
+ logger.info "[Postburner::Scheduler] Scheduler run complete"
145
+ else
146
+ logger.info "[Postburner::Scheduler] Could not acquire lock, skipping"
147
+ end
148
+ ensure
149
+ # Always re-queue watchdog for next run
150
+ requeue_watchdog
151
+ end
152
+
153
+ # Class method to enqueue watchdog to Beanstalkd
154
+ # Mutex for coordinating watchdog checks across threads
155
+ WATCHDOG_MUTEX = Mutex.new
156
+ @watchdog_last_checked_at = nil
157
+ @watchdog_check_failed_at = nil
158
+
159
+ class << self
160
+ attr_accessor :watchdog_last_checked_at, :watchdog_check_failed_at
161
+ end
162
+
163
+ # Ensure a scheduler watchdog exists in the queue.
164
+ #
165
+ # Uses process-level mutex coordination so only one thread checks at a time.
166
+ # Called by workers on reserve timeout to automatically recreate watchdog
167
+ # if it's missing (e.g., after Beanstalkd restart or watchdog expiration).
168
+ #
169
+ # Implements throttling:
170
+ # - Skips check if successful check within last 60 seconds
171
+ # - Skips check if failed check within last 60 seconds
172
+ # - Only one thread can check at a time (mutex)
173
+ #
174
+ # @param connection [Postburner::Connection] Existing beanstalkd connection
175
+ # @return [void]
176
+ #
177
+ # @example Called by worker on timeout
178
+ # Postburner.connected do |conn|
179
+ # Postburner::Scheduler.ensure_watchdog!(connection: conn)
180
+ # end
181
+ #
182
+ def self.ensure_watchdog!(connection:)
183
+ # Quick check without lock - skip if recently checked
184
+ return if watchdog_last_checked_at && Time.current - watchdog_last_checked_at < 60
185
+ return if watchdog_check_failed_at && Time.current - watchdog_check_failed_at < 60
186
+
187
+ # Try to acquire lock, skip if another thread is checking
188
+ return unless WATCHDOG_MUTEX.try_lock
189
+
190
+ begin
191
+ # Double-check after acquiring lock
192
+ return if watchdog_last_checked_at && Time.current - watchdog_last_checked_at < 60
193
+
194
+ if watchdog_exists?(connection: connection)
195
+ self.watchdog_last_checked_at = Time.current
196
+ return
197
+ end
198
+
199
+ enqueue_watchdog
200
+ self.watchdog_last_checked_at = Time.current
201
+ self.watchdog_check_failed_at = nil
202
+ rescue => e
203
+ self.watchdog_check_failed_at = Time.current
204
+ Rails.logger.error "[Postburner::Scheduler] Watchdog check failed, will retry in 60s: #{e.message}"
205
+ ensure
206
+ WATCHDOG_MUTEX.unlock
207
+ end
208
+ end
209
+
210
+ # Enqueue a new scheduler watchdog job.
211
+ #
212
+ # Creates a watchdog job in the scheduler tube with a delay equal to the interval.
213
+ # The watchdog will execute after the delay, process all schedules, and re-queue itself.
214
+ #
215
+ # Reads interval and priority from configuration if not provided:
216
+ # - default_scheduler_interval (default: 300 seconds)
217
+ # - default_scheduler_priority (default: 100)
218
+ #
219
+ # @param interval [Integer, nil] Seconds until next run (default: from config)
220
+ # @param priority [Integer, nil] Beanstalkd priority (default: from config)
221
+ # @return [Hash] Beanstalkd response with :status and :id keys
222
+ #
223
+ # @example Enqueue with defaults
224
+ # Postburner::Scheduler.enqueue_watchdog
225
+ # # => { status: "INSERTED", id: 12345 }
226
+ #
227
+ # @example Enqueue with custom interval
228
+ # Postburner::Scheduler.enqueue_watchdog(interval: 60, priority: 0)
229
+ #
230
+ def self.enqueue_watchdog(interval: nil, priority: nil)
231
+ interval ||= Postburner.configuration.default_scheduler_interval || DEFAULT_INTERVAL
232
+ priority ||= Postburner.configuration.default_scheduler_priority || DEFAULT_PRIORITY
233
+
234
+ payload = {
235
+ 'scheduler' => true,
236
+ 'interval' => interval
237
+ }
238
+
239
+ tube_name = Postburner.scheduler_tube_name
240
+ response = nil
241
+
242
+ Postburner.connected do |conn|
243
+ response = conn.tubes[tube_name].put(
244
+ JSON.generate(payload),
245
+ pri: priority,
246
+ delay: interval,
247
+ ttr: 120 # 2 minutes to complete
248
+ )
249
+ end
250
+
251
+ if response[:status] == "INSERTED"
252
+ runs_at = Time.current + interval
253
+ Rails.logger.info "[Postburner::Scheduler] Inserted watchdog: #{response[:id]} (#{Time.current.iso8601}, delay: #{interval}s (#{runs_at.iso8601}), tube: #{tube_name})"
254
+ else
255
+ Rails.logger.error "[Postburner::Scheduler] Failed to insert watchdog: #{response.inspect} (delay: #{interval}s, tube: #{tube_name})"
256
+ end
257
+
258
+ response
259
+ end
260
+
261
+ # Check if scheduler watchdog exists in queue.
262
+ #
263
+ # Peeks both the delayed and ready queues in the scheduler tube.
264
+ # Returns true if watchdog exists in either state.
265
+ #
266
+ # @param connection [Postburner::Connection] Existing beanstalkd connection
267
+ # @return [Boolean] true if watchdog exists (delayed or ready), false otherwise
268
+ #
269
+ # @example Check for watchdog
270
+ # Postburner.connected do |conn|
271
+ # exists = Postburner::Scheduler.watchdog_exists?(connection: conn)
272
+ # puts "Watchdog present" if exists
273
+ # end
274
+ #
275
+ def self.watchdog_exists?(connection:)
276
+ tube_name = Postburner.scheduler_tube_name
277
+ tube = connection.beanstalk.tubes[tube_name]
278
+
279
+ # Peek is lighter than stats
280
+ delayed = tube.peek(:delayed) rescue nil
281
+ ready = tube.peek(:ready) rescue nil
282
+
283
+ delayed.present? || ready.present?
284
+ rescue Beaneater::NotFoundError
285
+ false
286
+ end
287
+
288
+ private
289
+
290
+ # Process all enabled schedules.
291
+ #
292
+ # Iterates through all enabled schedules and calls process_schedule for each.
293
+ # Logs count of successful and failed schedule processing.
294
+ #
295
+ # @return [void]
296
+ #
297
+ # @api private
298
+ #
299
+ def process_all_schedules
300
+ processed_count = 0
301
+ failed_count = 0
302
+
303
+ Postburner::Schedule.enabled.find_each do |schedule|
304
+ begin
305
+ process_schedule(schedule)
306
+ processed_count += 1
307
+ rescue => e
308
+ logger.error "[Postburner::Scheduler] Failed to process schedule '#{schedule.name}': #{e.class} - #{e.message}"
309
+ logger.error e.backtrace.join("\n")
310
+ failed_count += 1
311
+ end
312
+ end
313
+
314
+ logger.info "[Postburner::Scheduler] Processed #{processed_count} schedules, #{failed_count} failed"
315
+ end
316
+
317
+ # Process a single schedule.
318
+ #
319
+ # The watchdog performs three safety net functions:
320
+ # 1. Auto-bootstrap: Create first execution if schedule hasn't been started
321
+ # 2. Ensure there's always a future execution (creates + enqueues if missing)
322
+ # 3. Enqueue any orphaned pending executions (that somehow weren't enqueued)
323
+ #
324
+ # Note: We check for future executions FIRST, then clean up any orphans.
325
+ # This ensures newly created executions are available for processing in the
326
+ # same run if they happen to be due.
327
+ #
328
+ # @param schedule [Postburner::Schedule] The schedule to process
329
+ # @return [void]
330
+ #
331
+ # @api private
332
+ #
333
+ def process_schedule(schedule)
334
+ # Auto-bootstrap: create first execution if schedule hasn't been started
335
+ # This will create the execution AND enqueue it to Beanstalkd
336
+ unless schedule.started?
337
+ logger.info "[Postburner::Scheduler] Bootstrapping schedule '#{schedule.name}'"
338
+ schedule.start!
339
+ end
340
+
341
+ # Safety net 1: Ensure schedule has a future execution
342
+ # If missing, this will create AND enqueue it to Beanstalkd's delayed queue
343
+ ensure_future_execution!(schedule)
344
+
345
+ # Safety net 2: Find any orphaned pending executions and enqueue them
346
+ # This should rarely happen - only if enqueue! previously failed
347
+ execution_count = 0
348
+ schedule.executions.due.find_each do |execution|
349
+ begin
350
+ logger.warn "[Postburner::Scheduler] Found orphaned pending execution #{execution.id} for schedule '#{schedule.name}', enqueuing"
351
+ execution.enqueue!
352
+ execution_count += 1
353
+ rescue => e
354
+ logger.error "[Postburner::Scheduler] Failed to enqueue execution #{execution.id}: #{e.class} - #{e.message}"
355
+ raise
356
+ end
357
+ end
358
+
359
+ # Update last_audit_at
360
+ schedule.update_column(:last_audit_at, Time.current)
361
+
362
+ logger.debug "[Postburner::Scheduler] Schedule '#{schedule.name}': enqueued #{execution_count} orphaned executions" if execution_count > 0
363
+ end
364
+
365
+ # Ensure schedule has a future scheduled or pending execution.
366
+ #
367
+ # Uses Schedule#create_next_execution! which is idempotent and only
368
+ # creates an execution if none exists in the future. If no executions
369
+ # exist at all, bootstraps the schedule first.
370
+ #
371
+ # This is the key safety net that ensures every schedule always has
372
+ # at least one future execution ready to run.
373
+ #
374
+ # @param schedule [Postburner::Schedule] The schedule to ensure future execution for
375
+ # @return [void]
376
+ #
377
+ # @api private
378
+ #
379
+ def ensure_future_execution!(schedule)
380
+ # Find the most recent execution to calculate next from
381
+ last_execution = schedule.executions.order(run_at: :desc).first
382
+
383
+ unless last_execution
384
+ # No executions at all - this shouldn't happen since we bootstrap first,
385
+ # but handle it as a safety net
386
+ logger.warn "[Postburner::Scheduler] Schedule '#{schedule.name}' has no executions, bootstrapping"
387
+ schedule.start!
388
+ return
389
+ end
390
+
391
+ # Delegate to Schedule - it knows whether a future execution is needed
392
+ execution = schedule.create_next_execution!(after: last_execution)
393
+
394
+ if execution
395
+ logger.info "[Postburner::Scheduler] Created future execution for '#{schedule.name}'"
396
+ end
397
+ end
398
+
399
+ # Re-queue the watchdog for the next run (if not already queued).
400
+ #
401
+ # Called in the ensure block of perform to guarantee the watchdog
402
+ # perpetuates itself. Always reads interval from config to pick up
403
+ # any runtime configuration changes.
404
+ #
405
+ # If re-queueing fails, logs error but doesn't raise - workers will
406
+ # recreate the watchdog on next timeout via ensure_watchdog!
407
+ #
408
+ # @return [void]
409
+ #
410
+ # @api private
411
+ #
412
+ def requeue_watchdog
413
+ Postburner.connected do |conn|
414
+ if self.class.watchdog_exists?(connection: conn)
415
+ logger.debug "[Postburner::Scheduler] Watchdog already exists, skipping re-queue"
416
+ return
417
+ end
418
+
419
+ self.class.enqueue_watchdog
420
+ end
421
+ rescue => e
422
+ logger.error "[Postburner::Scheduler] Failed to re-queue watchdog: #{e.class} - #{e.message}"
423
+ # This is critical - if watchdog isn't re-queued, scheduling stops
424
+ # Workers will recreate it on next timeout, but log loudly
425
+ end
426
+ end
427
+ end
@@ -105,6 +105,10 @@ module Postburner
105
105
  # is invoked. If the job has a future run_at, travels to that time before
106
106
  # execution. Otherwise executes immediately.
107
107
  #
108
+ # Uses recursion detection to prevent infinite loops when jobs create and
109
+ # enqueue other jobs (e.g., schedule callbacks). Jobs enqueued while already
110
+ # inside a perform! call are queued but not executed immediately.
111
+ #
108
112
  # @param job [Postburner::Job] The job to execute
109
113
  # @param options [Hash] Unused in test mode
110
114
  #
@@ -115,17 +119,30 @@ module Postburner
115
119
  # @api private
116
120
  #
117
121
  def insert(job, options = {})
118
- # If job has a future run_at, travel to that time for execution
119
- if job.run_at && job.run_at > Time.zone.now
120
- travel_to(job.run_at) do
122
+ # Detect recursion: if we're already performing a job, don't execute
123
+ # the newly enqueued job immediately. This prevents infinite loops
124
+ # when job callbacks (like schedule_next_execution) enqueue new jobs.
125
+ if Thread.current[:postburner_performing]
126
+ return { status: 'INLINE_DEFERRED', id: nil }
127
+ end
128
+
129
+ begin
130
+ Thread.current[:postburner_performing] = true
131
+
132
+ # If job has a future run_at, travel to that time for execution
133
+ if job.run_at && job.run_at > Time.current
134
+ travel_to(job.run_at) do
135
+ job.perform!(job.args)
136
+ end
137
+ else
138
+ # No future run_at, execute normally
121
139
  job.perform!(job.args)
122
140
  end
123
- else
124
- # No future run_at, execute normally
125
- job.perform!(job.args)
141
+ ensure
142
+ Thread.current[:postburner_performing] = false
126
143
  end
127
144
 
128
- # Return format matching Backburner response (symbol keys)
145
+ # Return format matching Beanstalkd response (symbol keys)
129
146
  { status: 'INLINE', id: nil }
130
147
  end
131
148
  end
@@ -77,7 +77,7 @@ module Postburner
77
77
  # @note Logs "PREMATURE; RE-INSERTED" message to job's audit trail
78
78
  #
79
79
  def handle_premature_perform(job)
80
- response = job.insert! delay: job.run_at - Time.zone.now
80
+ response = job.insert! delay: job.run_at - Time.current
81
81
  job.log! "PREMATURE; RE-INSERTED: #{response}"
82
82
  end
83
83
  end
@@ -88,7 +88,7 @@ module Postburner
88
88
  # @api private
89
89
  #
90
90
  def insert(job, options = {})
91
- # Return format matching Backburner response (symbol keys)
91
+ # Return format matching Beanstalkd response (symbol keys)
92
92
  { status: 'NULL', id: nil }
93
93
  end
94
94
 
@@ -118,7 +118,7 @@ module Postburner
118
118
  # @api private
119
119
  #
120
120
  def handle_perform!(job)
121
- if job.run_at && job.run_at > Time.zone.now
121
+ if job.run_at && job.run_at > Time.current
122
122
  travel_to(job.run_at) do
123
123
  job.perform!(job.args)
124
124
  end
@@ -105,7 +105,7 @@ module Postburner
105
105
  # Will raise PrematurePerform if run_at is in the future
106
106
  job.perform!(job.args)
107
107
 
108
- # Return format matching Backburner response (symbol keys)
108
+ # Return format matching Beanstalkd response (symbol keys)
109
109
  { status: 'INLINE', id: nil }
110
110
  end
111
111
 
@@ -121,7 +121,7 @@ module Postburner
121
121
  # @raise [Postburner::Job::PrematurePerform] Always raises with helpful message
122
122
  #
123
123
  def handle_premature_perform(job)
124
- raise Postburner::Job::PrematurePerform, "Job scheduled for #{job.run_at} (#{((job.run_at - Time.zone.now) / 60).round(1)} minutes from now). Use `travel_to(job.run_at)` in your test, or set `Postburner.inline_immediate_test_strategy!` to execute scheduled jobs immediately."
124
+ raise Postburner::Job::PrematurePerform, "Job scheduled for #{job.run_at} (#{((job.run_at - Time.current) / 60).round(1)} minutes from now). Use `travel_to(job.run_at)` in your test, or set `Postburner.inline_immediate_test_strategy!` to execute scheduled jobs immediately."
125
125
  end
126
126
  end
127
127
  end
@@ -18,7 +18,8 @@ module Postburner
18
18
  #
19
19
  # travel_to(2.days.from_now) do
20
20
  # # Code here executes as if it's 2 days in the future
21
- # Time.zone.now # => 2 days from now
21
+ # Time.current # => 2 days from now (preferred in Rails)
22
+ # Time.zone.now # => 2 days from now (equivalent)
22
23
  # end
23
24
  #
24
25
  # @example In a class method
@@ -54,7 +55,8 @@ module Postburner
54
55
  #
55
56
  # @example Travel to specific time
56
57
  # travel_to(Time.zone.parse('2025-12-25 00:00:00')) do
57
- # puts Time.zone.now # => 2025-12-25 00:00:00
58
+ # puts Time.current # => 2025-12-25 00:00:00 (preferred)
59
+ # puts Time.zone.now # => 2025-12-25 00:00:00 (equivalent)
58
60
  # end
59
61
  #
60
62
  # @example Travel relative to now
@@ -31,7 +31,11 @@ module Postburner
31
31
  # Just pass the last known id to after for the next batch.
32
32
  #
33
33
  def jobs(count=20, limit: 1000, after: nil)
34
+ # Access raw hash to avoid beaneater FastStruct method definition issues in Ruby 3.4
34
35
  stats = @tube.stats
36
+ stats_hash = stats.instance_variable_get(:@hash) || {}
37
+ tube_name = stats_hash['name']
38
+
35
39
  jobs = Array.new
36
40
 
37
41
  min_known = (
@@ -42,7 +46,11 @@ module Postburner
42
46
 
43
47
  for i in min..max
44
48
  job = @tube.client.jobs.find(i)
45
- jobs << job if job && stats[:name] == job.stats[:tube]
49
+ if job
50
+ job_stats = job.stats
51
+ job_stats_hash = job_stats.instance_variable_get(:@hash) || {}
52
+ jobs << job if job_stats_hash['tube'] == tube_name
53
+ end
46
54
  break if jobs.length >= count
47
55
  end
48
56
 
@@ -1,3 +1,3 @@
1
1
  module Postburner
2
- VERSION = '1.0.0.pre.11'
2
+ VERSION = '1.0.0.pre.13'
3
3
  end