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.
- checksums.yaml +4 -4
- data/README.md +983 -553
- data/app/concerns/postburner/commands.rb +1 -1
- data/app/concerns/postburner/execution.rb +11 -11
- data/app/concerns/postburner/insertion.rb +4 -1
- data/app/concerns/postburner/logging.rb +2 -2
- data/app/concerns/postburner/statistics.rb +1 -1
- data/app/models/postburner/job.rb +27 -4
- data/app/models/postburner/mailer.rb +1 -1
- data/app/models/postburner/schedule.rb +722 -0
- data/app/models/postburner/schedule_execution.rb +353 -0
- data/app/views/postburner/jobs/show.html.haml +3 -3
- data/lib/generators/postburner/install/install_generator.rb +1 -0
- data/lib/generators/postburner/install/templates/config/postburner.yml +15 -6
- data/lib/generators/postburner/install/templates/migrations/create_postburner_schedules.rb.erb +71 -0
- data/lib/postburner/active_job/adapter.rb +50 -19
- data/lib/postburner/active_job/payload.rb +5 -0
- data/lib/postburner/advisory_lock.rb +123 -0
- data/lib/postburner/configuration.rb +48 -7
- data/lib/postburner/connection.rb +7 -6
- data/lib/postburner/runner.rb +26 -3
- data/lib/postburner/scheduler.rb +427 -0
- data/lib/postburner/strategies/immediate_test_queue.rb +24 -7
- data/lib/postburner/strategies/nice_queue.rb +1 -1
- data/lib/postburner/strategies/null_queue.rb +2 -2
- data/lib/postburner/strategies/test_queue.rb +2 -2
- data/lib/postburner/time_helpers.rb +4 -2
- data/lib/postburner/tube.rb +9 -1
- data/lib/postburner/version.rb +1 -1
- data/lib/postburner/worker.rb +684 -0
- data/lib/postburner.rb +32 -13
- metadata +7 -3
- data/lib/postburner/workers/base.rb +0 -205
- 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
|
-
#
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
job.perform!(job.args)
|
|
141
|
+
ensure
|
|
142
|
+
Thread.current[:postburner_performing] = false
|
|
126
143
|
end
|
|
127
144
|
|
|
128
|
-
# Return format matching
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
data/lib/postburner/tube.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
data/lib/postburner/version.rb
CHANGED