postburner 1.0.0.pre.11 → 1.0.0.pre.12

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 +961 -555
  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 +1 -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 +703 -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 +3 -3
  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 +43 -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,353 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postburner
4
+ # ScheduleExecution represents a single scheduled run of a recurring job.
5
+ #
6
+ # Executions are created in advance and immediately enqueued to Beanstalkd's delayed
7
+ # queue. This ensures all future runs are already queued and ready to execute at the
8
+ # scheduled time, regardless of scheduler availability.
9
+ #
10
+ # ## Lifecycle
11
+ #
12
+ # 1. Execution created with `pending` status and immediately enqueued to Beanstalkd
13
+ # 2. Status changes to `scheduled` once enqueued
14
+ # 3. At `run_at` time, Beanstalkd releases job to worker
15
+ # 4. For Postburner::Job schedules: `before_attempt` callback creates next execution
16
+ # 5. Job completes (tracked in job record, not execution)
17
+ # 6. Watchdog periodically verifies future executions exist (safety net)
18
+ #
19
+ # ## Status Values
20
+ #
21
+ # - `pending` - Created but not yet enqueued to Beanstalkd
22
+ # - `scheduled` - Enqueued to Beanstalkd, waiting for execution
23
+ # - `skipped` - Cancelled by admin before execution
24
+ #
25
+ # Note: We don't track running/completed/failed states here because the
26
+ # actual job (Postburner::Job or ActiveJob) handles its own lifecycle.
27
+ # The execution's job is done once it's scheduled.
28
+ #
29
+ # ## Database Fields
30
+ #
31
+ # - `schedule_id` - Foreign key to parent schedule
32
+ # - `run_at` - Scheduled execution time
33
+ # - `next_run_at` - Pre-calculated next run time for validation
34
+ # - `enqueued_at` - When job was queued to Beanstalkd
35
+ # - `status` - Current status (pending, scheduled, skipped)
36
+ # - `beanstalk_job_id` - Beanstalkd job ID (for tracking/cancellation)
37
+ # - `job_id` - Postburner::Job ID (if using Postburner::Job subclass)
38
+ # - `cached_schedule` - JSONB snapshot of schedule at creation time
39
+ #
40
+ # ## Immediate Enqueue Architecture
41
+ #
42
+ # Unlike traditional schedulers that poll for due jobs, Postburner immediately
43
+ # enqueues executions to Beanstalkd's delayed queue when created. This means:
44
+ # - Jobs are already queued and will run at run_at regardless of scheduler status
45
+ # - No polling overhead - Beanstalkd handles delayed delivery
46
+ # - Watchdog only acts as safety net to ensure future executions exist
47
+ #
48
+ # @example Viewing execution details
49
+ # execution = schedule.executions.last
50
+ # execution.run_at # => 2025-12-29 09:00:00 UTC
51
+ # execution.enqueued_at # => 2025-12-28 10:00:00 UTC
52
+ # execution.status # => "scheduled"
53
+ # execution.beanstalk_job_id # => 12345
54
+ #
55
+ # @example Skipping a future execution
56
+ # execution = schedule.executions.future.first
57
+ # execution.skip! # Cancels Beanstalkd job and marks as skipped
58
+ #
59
+ # @see Postburner::Schedule
60
+ # @see Postburner::Scheduler
61
+ #
62
+ class ScheduleExecution < ApplicationRecord
63
+ # Status enum - simplified to only what ScheduleExecution needs to know
64
+ enum :status, {
65
+ pending: 0, # Created, waiting to be enqueued
66
+ scheduled: 11, # Enqueued to Beanstalkd
67
+ skipped: 101 # Cancelled before execution
68
+ }
69
+
70
+ # Associations
71
+ belongs_to :schedule
72
+ belongs_to :job, optional: true
73
+
74
+ # Validations
75
+ validates :run_at, presence: true, uniqueness: { scope: :schedule_id }
76
+ validates :status, presence: true
77
+ validate :validate_next_run_at!
78
+
79
+ # Scopes
80
+ scope :due, -> { pending.where('run_at <= ?', Time.current).where(enqueued_at: nil) }
81
+ scope :future, -> { where('run_at > ?', Time.current).order(run_at: :asc) }
82
+ scope :past, -> { where('run_at <= ?', Time.current).order(run_at: :desc) }
83
+
84
+ # Check if execution has been enqueued to Beanstalkd.
85
+ #
86
+ # @return [Boolean] true if execution has beanstalk_job_id and enqueued_at set
87
+ def enqueued?
88
+ beanstalk_job_id.present? && enqueued_at.present?
89
+ end
90
+
91
+ # Enqueue this execution to Beanstalkd.
92
+ #
93
+ # Creates the appropriate job (ActiveJob or Postburner::Job) and queues it
94
+ # to Beanstalkd at the scheduled run_at time with appropriate delay.
95
+ #
96
+ # The job type is determined by the job_class in cached_schedule:
97
+ # - Postburner::Job subclasses: Creates tracked job with database record
98
+ # - ActiveJob classes: Enqueues directly without database tracking
99
+ #
100
+ # This method is idempotent - calling it multiple times will only enqueue once.
101
+ #
102
+ # @return [void]
103
+ #
104
+ # @note Creating the next execution is the scheduler's responsibility via
105
+ # Schedule#create_next_execution!, not this method's responsibility.
106
+ #
107
+ # @example Enqueue an execution
108
+ # execution = schedule.executions.build(run_at: 1.hour.from_now)
109
+ # execution.save!
110
+ # execution.enqueue! # Creates and queues job to Beanstalkd
111
+ #
112
+ def enqueue!
113
+ return if enqueued?
114
+
115
+ # Mark as scheduled BEFORE Beanstalkd operations to prevent duplicates.
116
+ # If Beanstalkd put fails, we have a scheduled execution with no beanstalk_job_id,
117
+ # which the watchdog can detect and re-queue. This is safer than having a
118
+ # pending execution with a job already in Beanstalkd (which causes duplicates).
119
+ transaction do
120
+ update_columns(
121
+ enqueued_at: Time.current,
122
+ status: self.class.statuses[:scheduled]
123
+ )
124
+
125
+ if tracked_job?
126
+ enqueue_tracked_job!
127
+ else
128
+ enqueue_default_job!
129
+ end
130
+ end
131
+ end
132
+
133
+ # Skip this execution.
134
+ #
135
+ # Cancels the Beanstalkd job if already enqueued and marks the execution
136
+ # as skipped. Use this when an admin wants to cancel a scheduled execution.
137
+ #
138
+ # This is a destructive operation - skipped executions cannot be unskipped.
139
+ # A new execution must be created if needed.
140
+ #
141
+ # @return [Boolean] true if skipped successfully, false if already skipped
142
+ #
143
+ # @example Skip a future execution
144
+ # execution = schedule.executions.future.first
145
+ # execution.skip! # Cancels Beanstalkd job and marks as skipped
146
+ #
147
+ def skip!
148
+ return false if skipped?
149
+
150
+ transaction do
151
+ # Cancel the Beanstalkd job if it exists
152
+ if beanstalk_job_id.present?
153
+ cancel_beanstalk_job!
154
+ end
155
+
156
+ update!(status: :skipped)
157
+ end
158
+
159
+ true
160
+ end
161
+
162
+ # Validate that the next execution matches the cached next_run_at.
163
+ #
164
+ # Similar to Flex::SubscriptionIteration validation. Ensures that the
165
+ # pre-calculated next_run_at in this execution matches the actual run_at
166
+ # of the next execution created.
167
+ #
168
+ # This validation helps detect calculation bugs or manual tampering with
169
+ # execution records.
170
+ #
171
+ # @param next_execution [ScheduleExecution] The next execution to validate
172
+ # @return [Boolean] true if validation passes or next_run_at is nil
173
+ # @raise [NextExecutionRunAtConflict] if times don't match
174
+ #
175
+ # @example Validate next execution
176
+ # current = schedule.executions.first
177
+ # next_exec = schedule.executions.second
178
+ # current.validate_next_execution!(next_exec) # raises if mismatch
179
+ #
180
+ def validate_next_execution!(next_execution)
181
+ return true if next_run_at.nil?
182
+
183
+ # Check if the run_at matches down to the second
184
+ if next_execution.run_at.to_i == Time.parse(next_run_at.to_s).to_i
185
+ return true
186
+ end
187
+
188
+ raise NextExecutionRunAtConflict,
189
+ "ScheduleExecution #{id} has next_run_at #{next_run_at} but next execution has run_at #{next_execution.run_at}"
190
+ end
191
+
192
+ private
193
+
194
+ # Check if this job is a Postburner::Job subclass (always tracked).
195
+ #
196
+ # @return [Boolean] true if job_class is a Postburner::Job subclass
197
+ #
198
+ # @api private
199
+ #
200
+ def tracked_job?
201
+ klass = cached_schedule['job_class'].constantize
202
+ klass < Postburner::Job
203
+ rescue NameError
204
+ false
205
+ end
206
+
207
+ # Check if this job is an ActiveJob that includes Postburner::Tracked.
208
+ #
209
+ # @return [Boolean] true if job_class includes Postburner::Tracked
210
+ #
211
+ # @api private
212
+ #
213
+ def tracked_activejob?
214
+ klass = cached_schedule['job_class'].constantize
215
+ klass.included_modules.include?(Postburner::Tracked)
216
+ rescue NameError
217
+ false
218
+ end
219
+
220
+ # Enqueue a tracked Postburner::Job.
221
+ #
222
+ # Creates a Postburner::Job record, configures it with cached schedule
223
+ # attributes, and enqueues it to Beanstalkd with the scheduled run_at time.
224
+ #
225
+ # @return [void]
226
+ #
227
+ # @api private
228
+ #
229
+ def enqueue_tracked_job!
230
+ klass = cached_schedule['job_class'].constantize
231
+ job = klass.create!(args: cached_schedule['args'] || {})
232
+
233
+ # Set optional configuration
234
+ job.priority = cached_schedule['priority'] if cached_schedule['priority']
235
+ job.queue_name = cached_schedule['queue'] if cached_schedule['queue']
236
+
237
+ # Link execution to job BEFORE queueing so the schedule_next_execution
238
+ # callback can find the schedule_execution association during perform!
239
+ update_columns(job_id: job.id)
240
+
241
+ # Queue the job
242
+ bkid = job.queue!(at: run_at)
243
+
244
+ # Update beanstalk_job_id after queue
245
+ update_columns(beanstalk_job_id: bkid)
246
+ end
247
+
248
+ # Enqueue a default ActiveJob.
249
+ #
250
+ # Creates and enqueues an ActiveJob instance with the scheduled run_at time.
251
+ # Handles both Hash args (passed as keyword arguments) and Array args (splatted).
252
+ #
253
+ # For tracked ActiveJobs (those including Postburner::Tracked), the adapter
254
+ # creates a TrackedJob record. We link that record back to this execution
255
+ # to enable the schedule_next_execution callback.
256
+ #
257
+ # @return [void]
258
+ #
259
+ # @api private
260
+ #
261
+ def enqueue_default_job!
262
+ klass = cached_schedule['job_class'].constantize
263
+ args = cached_schedule['args']
264
+ is_tracked = tracked_activejob?
265
+
266
+ # Build job with configuration
267
+ # Hash args are passed as keyword arguments, Array args are splatted
268
+ job = if args.is_a?(Hash) && args.any?
269
+ klass.new(**args.symbolize_keys)
270
+ elsif args.is_a?(Array)
271
+ klass.new(*args)
272
+ else
273
+ klass.new
274
+ end
275
+ job.queue_name = cached_schedule['queue'] if cached_schedule['queue']
276
+ job.priority = cached_schedule['priority'] if cached_schedule['priority']
277
+
278
+ # Enqueue with scheduled time (use instance method enqueue, not class method perform_later)
279
+ delay = (run_at - Time.current).to_i
280
+ delay = 0 if delay < 0
281
+
282
+ if delay > 0
283
+ job.enqueue(wait: delay)
284
+ else
285
+ job.enqueue
286
+ end
287
+
288
+ # For tracked ActiveJobs, link to the TrackedJob created by the adapter
289
+ if is_tracked
290
+ tracked_job = Postburner::TrackedJob.where(
291
+ "args->>'job_id' = ?", job.job_id
292
+ ).order(created_at: :desc).first
293
+
294
+ if tracked_job
295
+ update_columns(
296
+ job_id: tracked_job.id,
297
+ beanstalk_job_id: tracked_job.bkid
298
+ )
299
+ else
300
+ update_columns(beanstalk_job_id: nil)
301
+ end
302
+ else
303
+ update_columns(beanstalk_job_id: nil)
304
+ end
305
+ end
306
+
307
+ # Cancel the Beanstalkd job.
308
+ #
309
+ # Uses Postburner::Job's API for tracked jobs, falls back to direct
310
+ # Beanstalkd deletion for ActiveJobs. Handles NotFoundError gracefully
311
+ # (job may have already been processed or deleted).
312
+ #
313
+ # @return [void]
314
+ #
315
+ # @api private
316
+ #
317
+ def cancel_beanstalk_job!
318
+ if job_id.present? && job.present?
319
+ # Tracked job - use Job's delete! method
320
+ job.delete!
321
+ elsif beanstalk_job_id.present?
322
+ # ActiveJob - delete directly from Beanstalkd
323
+ Postburner.connected do |conn|
324
+ begin
325
+ bk_job = conn.beanstalk.jobs.find(beanstalk_job_id)
326
+ bk_job&.delete
327
+ rescue Beaneater::NotFoundError
328
+ # Job already processed or deleted - that's fine
329
+ end
330
+ end
331
+ end
332
+ end
333
+
334
+ # Validate next_run_at is after run_at.
335
+ #
336
+ # Ensures that if next_run_at is set, it's chronologically after run_at.
337
+ # This catches calculation bugs where next_run_at is incorrectly set.
338
+ #
339
+ # @return [void]
340
+ #
341
+ # @api private
342
+ #
343
+ def validate_next_run_at!
344
+ return if next_run_at.nil?
345
+
346
+ if next_run_at <= run_at
347
+ errors.add(:next_run_at, 'must be after run_at')
348
+ end
349
+ end
350
+
351
+ class NextExecutionRunAtConflict < StandardError; end
352
+ end
353
+ end
@@ -1,9 +1,9 @@
1
1
  %h1 Job #{@job.id} / #{@job.bkid} / #{@job.sid}
2
2
 
3
- - if @job.run_at && @job.run_at > Time.zone.now
3
+ - if @job.run_at && @job.run_at > Time.current
4
4
  Running in
5
- = distance_of_time_in_words Time.zone.now, @job.run_at
6
- (#{@job.run_at.to_i - Time.zone.now.to_i} seconds)
5
+ = distance_of_time_in_words Time.current, @job.run_at
6
+ (#{@job.run_at.to_i - Time.current.to_i} seconds)
7
7
 
8
8
  %h3 Stats
9
9
  - if @job.bk
@@ -4,6 +4,7 @@ class Postburner::InstallGenerator < Rails::Generators::Base
4
4
 
5
5
  def install_migrations
6
6
  install_migration! 'create_postburner_jobs'
7
+ install_migration! 'create_postburner_schedules'
7
8
  end
8
9
 
9
10
  def copy_config_file
@@ -28,6 +28,13 @@ default: &default
28
28
  # Override with ENV['BEANSTALK_URL'] if set
29
29
  beanstalk_url: <%= ENV['BEANSTALK_URL'] || 'beanstalk://localhost:11300' %>
30
30
 
31
+ # Scheduler configuration
32
+ # The scheduler runs as a lightweight "watchdog" job that checks for due
33
+ # schedule executions and enqueues them. All workers automatically watch
34
+ # the scheduler queue and will process the watchdog when it becomes due.
35
+ default_scheduler_interval: 300 # Check for due schedules every 5 minutes (300 seconds)
36
+ default_scheduler_priority: 100 # Scheduler watchdog priority (lower = higher priority)
37
+
31
38
  development:
32
39
  <<: *default
33
40
 
@@ -136,9 +143,11 @@ production: # <- environment config, i.e. defaults, NOT worker config
136
143
 
137
144
  # Env-Level Defaults (can be overridden per worker):
138
145
  #
139
- # default_queue: default # Default queue name (optional)
140
- # default_priority: 65536 # Lower = higher priority (optional, 0 is highest)
141
- # default_ttr: 300 # Time-to-run in seconds (optional)
142
- # default_threads: 1 # Thread count per fork (optional, defaults to 1)
143
- # default_forks: 0 # Fork count (optional, defaults to 0 = single process)
144
- # default_gc_limit: nil # Exit after N jobs for restart (optional, nil = no limit)
146
+ # default_queue: default # Default queue name (optional)
147
+ # default_priority: 65536 # Lower = higher priority (optional, 0 is highest)
148
+ # default_ttr: 300 # Time-to-run in seconds (optional)
149
+ # default_threads: 1 # Thread count per fork (optional, defaults to 1)
150
+ # default_forks: 0 # Fork count (optional, defaults to 0 = single process)
151
+ # default_gc_limit: nil # Exit after N jobs for restart (optional, nil = no limit)
152
+ # default_scheduler_interval: 300 # Scheduler check interval in seconds (optional, default: 300)
153
+ # default_scheduler_priority: 100 # Scheduler watchdog priority (optional, default: 100)
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+ #
3
+ class CreatePostburnerSchedules < ActiveRecord::Migration<%= migration_version %>
4
+ def change
5
+ create_table :postburner_schedules do |t|
6
+ # Identity
7
+ t.string :name, null: false
8
+ t.string :job_class, null: false
9
+ t.jsonb :args, default: {}
10
+
11
+ # Configuration
12
+ t.string :queue
13
+ t.integer :priority
14
+ t.string :timezone, default: 'UTC', null: false
15
+
16
+ # Anchor-based scheduling (primary)
17
+ t.datetime :anchor
18
+ t.integer :interval
19
+ t.string :interval_unit
20
+
21
+ # Cron-based (power user, optional)
22
+ t.string :cron
23
+
24
+ # Control
25
+ t.boolean :enabled, default: true, null: false
26
+ t.boolean :catch_up, default: false, null: false
27
+ t.datetime :last_audit_at
28
+ t.text :description
29
+
30
+ t.timestamps
31
+
32
+ t.index :name, unique: true
33
+ t.index :job_class
34
+ t.index :enabled
35
+ t.index :last_audit_at
36
+ t.index :args, using: :gin
37
+ end
38
+
39
+ create_table :postburner_schedule_executions do |t|
40
+ # Relationship
41
+ t.bigint :schedule_id, null: false
42
+
43
+ # Timing
44
+ t.datetime :run_at, null: false
45
+ t.datetime :next_run_at
46
+ t.datetime :enqueued_at
47
+
48
+ # Status: 0=pending, 1=scheduled, 2=skipped
49
+ t.integer :status, default: 0, null: false
50
+
51
+ # Job tracking
52
+ t.bigint :beanstalk_job_id
53
+ t.bigint :job_id
54
+
55
+ # Audit
56
+ t.jsonb :cached_schedule, default: {}
57
+
58
+ t.timestamps
59
+
60
+ t.index :schedule_id
61
+ t.index [:schedule_id, :run_at], unique: true
62
+ t.index [:status, :run_at], name: 'index_postburner_schedule_executions_on_status_and_run_at'
63
+ t.index :beanstalk_job_id
64
+ t.index :job_id
65
+ t.index :run_at
66
+
67
+ t.foreign_key :postburner_schedules, column: :schedule_id, on_delete: :cascade
68
+ t.foreign_key :postburner_jobs, column: :job_id, on_delete: :nullify
69
+ end
70
+ end
71
+ end
@@ -121,11 +121,11 @@ module ActiveJob
121
121
  tracked_job = Postburner::TrackedJob.create!(
122
122
  args: Postburner::ActiveJob::Payload.serialize_for_tracked(job),
123
123
  run_at: timestamp ? Time.zone.at(timestamp) : nil,
124
- queued_at: Time.zone.now
124
+ queued_at: Time.current
125
125
  )
126
126
 
127
127
  # Calculate delay for Beanstalkd
128
- delay = timestamp ? [(timestamp.to_f - Time.zone.now.to_f).to_i, 0].max : 0
128
+ delay = timestamp ? [timestamp.to_i - Time.current.to_i, 0].max : 0
129
129
 
130
130
  # Queue to Beanstalkd with minimal payload
131
131
  Postburner.connected do |conn|
@@ -160,7 +160,7 @@ module ActiveJob
160
160
  # @return [void]
161
161
  #
162
162
  def enqueue_default(job, timestamp)
163
- delay = timestamp ? [(timestamp.to_f - Time.zone.now.to_f).to_i, 0].max : 0
163
+ delay = timestamp ? [timestamp.to_i - Time.current.to_i, 0].max : 0
164
164
 
165
165
  Postburner.connected do |conn|
166
166
  tube_name = expand_tube_name(job.queue_name)
@@ -54,6 +54,10 @@ module Postburner
54
54
  # @api private
55
55
  #
56
56
  def for_job(job, tracked: false, postburner_job_id: nil)
57
+ # Get TTR from job class if available, otherwise use default
58
+ ttr = job.class.respond_to?(:postburner_ttr) && job.class.postburner_ttr ||
59
+ Postburner.configuration.default_ttr
60
+
57
61
  {
58
62
  v: VERSION,
59
63
  tracked: tracked,
@@ -62,6 +66,7 @@ module Postburner
62
66
  job_id: job.job_id,
63
67
  queue_name: job.queue_name,
64
68
  priority: job.priority,
69
+ ttr: ttr,
65
70
  arguments: ::ActiveJob::Arguments.serialize(job.arguments),
66
71
  executions: job.executions,
67
72
  exception_executions: job.exception_executions || {},
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postburner
4
+ # PostgreSQL advisory lock support for coordinating distributed processes.
5
+ #
6
+ # Advisory locks allow multiple scheduler processes to coordinate without
7
+ # race conditions. Only one process can hold a specific lock at a time.
8
+ #
9
+ # @example Using with a block
10
+ # Postburner::AdvisoryLock.with_lock('postburner_scheduler') do
11
+ # # Only one process executes this block at a time
12
+ # process_due_schedules
13
+ # end
14
+ #
15
+ # @example Manual lock/unlock
16
+ # lock = Postburner::AdvisoryLock.new('postburner_scheduler')
17
+ # if lock.acquire
18
+ # begin
19
+ # process_due_schedules
20
+ # ensure
21
+ # lock.release
22
+ # end
23
+ # end
24
+ #
25
+ class AdvisoryLock
26
+ # Lock key for the scheduler process
27
+ SCHEDULER_LOCK_KEY = 'postburner_scheduler'
28
+
29
+ attr_reader :key, :connection
30
+
31
+ # Initialize a new advisory lock
32
+ #
33
+ # @param key [String] Unique lock identifier
34
+ # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] Database connection
35
+ def initialize(key, connection = nil)
36
+ @key = key
37
+ @connection = connection || ActiveRecord::Base.connection
38
+ @acquired = false
39
+ end
40
+
41
+ # Acquire the advisory lock (blocking)
42
+ #
43
+ # This will wait until the lock is available.
44
+ #
45
+ # @return [Boolean] true if lock acquired
46
+ def acquire
47
+ return true if @acquired
48
+
49
+ lock_id = generate_lock_id(key)
50
+ result = connection.execute("SELECT pg_advisory_lock(#{lock_id})")
51
+ @acquired = true
52
+ end
53
+
54
+ # Try to acquire the advisory lock (non-blocking)
55
+ #
56
+ # Returns immediately, indicating whether lock was acquired.
57
+ #
58
+ # @return [Boolean] true if lock acquired, false if already held
59
+ def try_acquire
60
+ return true if @acquired
61
+
62
+ lock_id = generate_lock_id(key)
63
+ result = connection.execute("SELECT pg_try_advisory_lock(#{lock_id})").first
64
+ value = result['pg_try_advisory_lock']
65
+ @acquired = value == true || value == 't'
66
+ end
67
+
68
+ # Release the advisory lock
69
+ #
70
+ # @return [Boolean] true if lock was held and released
71
+ def release
72
+ return false unless @acquired
73
+
74
+ lock_id = generate_lock_id(key)
75
+ result = connection.execute("SELECT pg_advisory_unlock(#{lock_id})").first
76
+ @acquired = false
77
+ value = result['pg_advisory_unlock']
78
+ value == true || value == 't'
79
+ end
80
+
81
+ # Check if this lock instance has acquired the lock
82
+ #
83
+ # @return [Boolean]
84
+ def acquired?
85
+ @acquired
86
+ end
87
+
88
+ # Execute a block with an advisory lock
89
+ #
90
+ # @param key [String] Lock identifier
91
+ # @param blocking [Boolean] If true, wait for lock. If false, return immediately if unavailable.
92
+ # @yield Block to execute while holding lock
93
+ # @return [Object, nil] Returns block result if lock acquired, nil otherwise
94
+ def self.with_lock(key, blocking: true)
95
+ lock = new(key)
96
+
97
+ acquired = blocking ? lock.acquire : lock.try_acquire
98
+ return nil unless acquired
99
+
100
+ begin
101
+ yield
102
+ ensure
103
+ lock.release
104
+ end
105
+ end
106
+
107
+ private
108
+
109
+ # Generate a numeric lock ID from a string key
110
+ #
111
+ # PostgreSQL advisory locks use bigint IDs. We generate a consistent
112
+ # numeric ID from the string key using a hash function.
113
+ #
114
+ # @param key [String] Lock key
115
+ # @return [Integer] Numeric lock ID
116
+ def generate_lock_id(key)
117
+ # Use CRC32 to generate a consistent numeric ID from the string
118
+ # This gives us a 32-bit integer which is well within PostgreSQL's bigint range
119
+ require 'zlib'
120
+ Zlib.crc32(key)
121
+ end
122
+ end
123
+ end