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.
- checksums.yaml +4 -4
- data/README.md +961 -555
- data/app/concerns/postburner/commands.rb +1 -1
- data/app/concerns/postburner/execution.rb +11 -11
- data/app/concerns/postburner/insertion.rb +1 -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 +703 -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 +3 -3
- data/lib/postburner/active_job/payload.rb +5 -0
- data/lib/postburner/advisory_lock.rb +123 -0
- data/lib/postburner/configuration.rb +43 -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,703 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Postburner
|
|
4
|
+
# Schedule model for recurring job execution with fixed-rate, grid-aligned scheduling.
|
|
5
|
+
#
|
|
6
|
+
# Postburner's scheduler provides predictable execution times with no drift, like
|
|
7
|
+
# subscription billing. Executions are calculated as anchor + N*interval, maintaining
|
|
8
|
+
# alignment to the grid regardless of actual execution times.
|
|
9
|
+
#
|
|
10
|
+
# ## Architecture
|
|
11
|
+
#
|
|
12
|
+
# The scheduler uses **immediate enqueue** combined with a **watchdog safety net**:
|
|
13
|
+
#
|
|
14
|
+
# 1. When an execution is created, it's immediately enqueued to Beanstalkd's delayed
|
|
15
|
+
# queue with the appropriate delay until run_at
|
|
16
|
+
# 2. For Postburner::Job-based schedules, a before_attempt callback creates the next
|
|
17
|
+
# execution when the current job runs - providing immediate pickup without waiting
|
|
18
|
+
# 3. A lightweight watchdog in the 'scheduler' tube acts as a safety net, ensuring
|
|
19
|
+
# every schedule has a future execution queued
|
|
20
|
+
#
|
|
21
|
+
# This design requires no dedicated scheduler process - existing workers handle everything.
|
|
22
|
+
#
|
|
23
|
+
# ## Scheduling Modes
|
|
24
|
+
#
|
|
25
|
+
# **Anchor-based (recommended):** Define a start time, interval, and unit (like subscriptions)
|
|
26
|
+
# - Supports: seconds, minutes, hours, days, weeks, months, years
|
|
27
|
+
# - Grid-aligned: Always snaps to anchor + N*interval, never drifts
|
|
28
|
+
# - Example: Daily at 9:00 AM, weekly on Saturday, monthly on the 1st
|
|
29
|
+
#
|
|
30
|
+
# **Cron-based:** Use standard cron expressions (requires fugit gem)
|
|
31
|
+
# - Power user feature for complex schedules
|
|
32
|
+
# - Example: Weekdays at 8 AM, every 15 minutes, etc.
|
|
33
|
+
#
|
|
34
|
+
# ## Database Fields
|
|
35
|
+
#
|
|
36
|
+
# - `name` - Unique identifier for the schedule
|
|
37
|
+
# - `job_class` - ActiveJob or Postburner::Job class name
|
|
38
|
+
# - `anchor` - Start time for interval calculation (anchor mode)
|
|
39
|
+
# - `interval` - Number of interval units (anchor mode)
|
|
40
|
+
# - `interval_unit` - Unit type: seconds/minutes/hours/days/weeks/months/years
|
|
41
|
+
# - `cron` - Cron expression (cron mode)
|
|
42
|
+
# - `timezone` - Timezone for calculations (default: UTC)
|
|
43
|
+
# - `args` - JSONB arguments passed to each job execution
|
|
44
|
+
# - `queue` - Override default queue name
|
|
45
|
+
# - `priority` - Override default Beanstalkd priority
|
|
46
|
+
# - `enabled` - Enable/disable schedule
|
|
47
|
+
# - `catch_up` - Skip missed executions (false) or run all (true)
|
|
48
|
+
# - `last_audit_at` - Last time watchdog processed this schedule
|
|
49
|
+
#
|
|
50
|
+
# ## Catch-Up Policy
|
|
51
|
+
#
|
|
52
|
+
# The catch_up attribute controls behavior when worker is down:
|
|
53
|
+
# - `catch_up: false` (default) - Skip missed executions, resume from next future time
|
|
54
|
+
# - `catch_up: true` - Run all missed executions when worker restarts
|
|
55
|
+
#
|
|
56
|
+
# ## Configuration
|
|
57
|
+
#
|
|
58
|
+
# Add to config/postburner.yml:
|
|
59
|
+
# production:
|
|
60
|
+
# default_scheduler_interval: 300 # Check every 5 minutes
|
|
61
|
+
# default_scheduler_priority: 100 # Watchdog priority
|
|
62
|
+
#
|
|
63
|
+
# ## Starting Schedules
|
|
64
|
+
#
|
|
65
|
+
# **Explicit start (immediate):**
|
|
66
|
+
# schedule.start! # Creates and enqueues first execution to Beanstalkd
|
|
67
|
+
#
|
|
68
|
+
# **Auto-bootstrap (eventual):**
|
|
69
|
+
# # Watchdog auto-bootstraps on next run (adds up to one interval of delay)
|
|
70
|
+
#
|
|
71
|
+
# @example Anchor-based schedule (daily at 9:30 AM)
|
|
72
|
+
# schedule = Postburner::Schedule.create!(
|
|
73
|
+
# name: 'daily_cleanup',
|
|
74
|
+
# job_class: 'CleanupJob',
|
|
75
|
+
# anchor: Time.zone.parse('2025-01-01 09:30:00'),
|
|
76
|
+
# interval: 1,
|
|
77
|
+
# interval_unit: 'days',
|
|
78
|
+
# timezone: 'America/New_York',
|
|
79
|
+
# args: { report_type: 'daily' }
|
|
80
|
+
# )
|
|
81
|
+
# schedule.start! # Optional: immediate pickup
|
|
82
|
+
#
|
|
83
|
+
# @example Cron-based schedule (weekdays at 8 AM)
|
|
84
|
+
# schedule = Postburner::Schedule.create!(
|
|
85
|
+
# name: 'weekday_standup',
|
|
86
|
+
# job_class: 'StandupReminderJob',
|
|
87
|
+
# cron: '0 8 * * 1-5',
|
|
88
|
+
# timezone: 'America/Chicago'
|
|
89
|
+
# )
|
|
90
|
+
#
|
|
91
|
+
# @example With catch-up enabled
|
|
92
|
+
# schedule = Postburner::Schedule.create!(
|
|
93
|
+
# name: 'billing_job',
|
|
94
|
+
# job_class: 'BillingJob',
|
|
95
|
+
# interval: 1,
|
|
96
|
+
# interval_unit: 'hours',
|
|
97
|
+
# catch_up: true # Run all missed hours if worker was down
|
|
98
|
+
# )
|
|
99
|
+
#
|
|
100
|
+
# @see Postburner::ScheduleExecution
|
|
101
|
+
# @see Postburner::Scheduler
|
|
102
|
+
#
|
|
103
|
+
class Schedule < ApplicationRecord
|
|
104
|
+
has_many :executions,
|
|
105
|
+
class_name: 'Postburner::ScheduleExecution',
|
|
106
|
+
dependent: :destroy
|
|
107
|
+
|
|
108
|
+
# Validations
|
|
109
|
+
validates :name, presence: true, uniqueness: true
|
|
110
|
+
validates :job_class, presence: true
|
|
111
|
+
validates :timezone, presence: true
|
|
112
|
+
validate :validate_scheduling_mode!
|
|
113
|
+
validate :validate_job_class_exists!
|
|
114
|
+
validate :validate_cron_expression!, if: :cron?
|
|
115
|
+
|
|
116
|
+
# Scopes
|
|
117
|
+
scope :enabled, -> { where(enabled: true) }
|
|
118
|
+
scope :disabled, -> { where(enabled: false) }
|
|
119
|
+
scope :anchor_based, -> { where.not(anchor: nil) }
|
|
120
|
+
scope :cron_based, -> { where.not(cron: nil) }
|
|
121
|
+
|
|
122
|
+
# Start the schedule by creating the first execution.
|
|
123
|
+
#
|
|
124
|
+
# Use this when you need the schedule to be picked up immediately
|
|
125
|
+
# (within the next scheduler interval). Without calling start!, the
|
|
126
|
+
# scheduler will auto-bootstrap the schedule on its next run, but
|
|
127
|
+
# that adds an extra interval of delay.
|
|
128
|
+
#
|
|
129
|
+
# This is an idempotent operation - calling it multiple times will
|
|
130
|
+
# only create the first execution once.
|
|
131
|
+
#
|
|
132
|
+
# @return [ScheduleExecution, nil] The created execution, or nil if already started
|
|
133
|
+
# @raise [ActiveRecord::RecordInvalid] If execution creation fails
|
|
134
|
+
#
|
|
135
|
+
# @example Start a new schedule
|
|
136
|
+
# schedule = Postburner::Schedule.create!(
|
|
137
|
+
# name: 'daily_cleanup',
|
|
138
|
+
# job_class: 'CleanupJob',
|
|
139
|
+
# anchor: Time.zone.now,
|
|
140
|
+
# interval: 1,
|
|
141
|
+
# interval_unit: 'days',
|
|
142
|
+
# timezone: 'UTC'
|
|
143
|
+
# )
|
|
144
|
+
# schedule.start! # Creates and enqueues first execution
|
|
145
|
+
#
|
|
146
|
+
def start!
|
|
147
|
+
return nil if started?
|
|
148
|
+
|
|
149
|
+
create_execution!
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Create the next execution if one doesn't already exist.
|
|
153
|
+
#
|
|
154
|
+
# Idempotent method that ensures exactly one future execution is scheduled.
|
|
155
|
+
# Used by Postburner::Job callback to provide immediate pickup without waiting
|
|
156
|
+
# for the scheduler watchdog. Safe to call multiple times - will only create
|
|
157
|
+
# an execution if none exists in the future.
|
|
158
|
+
#
|
|
159
|
+
# The catch_up attribute controls behavior when worker is down:
|
|
160
|
+
# - catch_up: true -> calculates from last execution (may be in past, runs immediately)
|
|
161
|
+
# - catch_up: false -> calculates from Time.current (skips missed executions)
|
|
162
|
+
#
|
|
163
|
+
# @param after [ScheduleExecution, Time, nil] The execution or time to calculate next from.
|
|
164
|
+
# If nil, behavior depends on catch_up setting. If ScheduleExecution, uses its run_at.
|
|
165
|
+
# @return [ScheduleExecution, nil] The created execution, or nil if one already exists
|
|
166
|
+
# or if a race condition occurred
|
|
167
|
+
#
|
|
168
|
+
# @example Create next execution after current
|
|
169
|
+
# last_execution = schedule.executions.last
|
|
170
|
+
# schedule.create_next_execution!(after: last_execution)
|
|
171
|
+
#
|
|
172
|
+
# @example With catch_up disabled (default)
|
|
173
|
+
# schedule.catch_up = false
|
|
174
|
+
# schedule.create_next_execution! # Calculates from Time.current
|
|
175
|
+
#
|
|
176
|
+
# @note This method handles race conditions gracefully - if two threads/processes
|
|
177
|
+
# try to create an execution simultaneously, one will succeed and the other
|
|
178
|
+
# will return nil with a warning logged.
|
|
179
|
+
#
|
|
180
|
+
def create_next_execution!(after: nil)
|
|
181
|
+
# Check if a future execution already exists (any status - including skipped).
|
|
182
|
+
#
|
|
183
|
+
# TIME PRECISION: When called from a job callback during time travel
|
|
184
|
+
# (e.g., ImmediateTestQueue), Time.current and execution.run_at can differ
|
|
185
|
+
# by microseconds:
|
|
186
|
+
#
|
|
187
|
+
# Time.current = 2025-12-29 06:54:58.000000 UTC (traveled, truncated)
|
|
188
|
+
# execution.run_at = 2025-12-29 06:54:58.185940 UTC (database precision)
|
|
189
|
+
#
|
|
190
|
+
# Using Time.current would incorrectly find the CURRENT execution as "future"
|
|
191
|
+
# (since 185940µs > 0µs), causing this method to return nil. By using
|
|
192
|
+
# after.run_at when an execution is provided, we correctly exclude the
|
|
193
|
+
# current execution from the future check.
|
|
194
|
+
check_time = after.is_a?(ScheduleExecution) ? after.run_at : Time.current
|
|
195
|
+
return nil if executions.where('run_at > ?', check_time).exists?
|
|
196
|
+
|
|
197
|
+
# Determine base time for calculating next execution.
|
|
198
|
+
#
|
|
199
|
+
# TIME PRECISION: When after is a current/future ScheduleExecution, we
|
|
200
|
+
# must use its run_at for calculation. If we used Time.current instead
|
|
201
|
+
# (which may be microseconds behind), next_run_at() could return the SAME
|
|
202
|
+
# time as the current execution, causing a duplicate key error.
|
|
203
|
+
#
|
|
204
|
+
# For past executions, respect the catch_up setting:
|
|
205
|
+
# catch_up: true -> calculate from last execution (runs missed jobs)
|
|
206
|
+
# catch_up: false -> calculate from Time.current (skips missed jobs)
|
|
207
|
+
after_time = if after.is_a?(ScheduleExecution) && after.run_at >= Time.current
|
|
208
|
+
after.run_at
|
|
209
|
+
elsif catch_up
|
|
210
|
+
after.is_a?(ScheduleExecution) ? after.run_at : after
|
|
211
|
+
else
|
|
212
|
+
Time.current
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
create_execution!(after: after_time)
|
|
216
|
+
rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid => e
|
|
217
|
+
# Race condition - another process/thread created it between our check and insert
|
|
218
|
+
# RecordNotUnique: PostgreSQL constraint violation
|
|
219
|
+
# RecordInvalid: Rails validation error (uniqueness validation catches it first)
|
|
220
|
+
Rails.logger.warn "[Postburner::Schedule] Expected race condition creating next execution for '#{name}' (job: #{id}): #{e.message}"
|
|
221
|
+
nil
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Calculate next N run times.
|
|
225
|
+
#
|
|
226
|
+
# Uses either cron or anchor-based calculation depending on schedule mode.
|
|
227
|
+
# All times are calculated in the schedule's timezone and returned as Time objects.
|
|
228
|
+
#
|
|
229
|
+
# @param after [Time, nil] Calculate times after this time (default: Time.current)
|
|
230
|
+
# @param count [Integer] Number of times to calculate (default: 1)
|
|
231
|
+
# @return [Array<Time>] Array of future run times in schedule's timezone
|
|
232
|
+
#
|
|
233
|
+
# @example Preview next 5 runs
|
|
234
|
+
# schedule.next_run_at_times(count: 5)
|
|
235
|
+
# # => [2025-12-29 09:00:00 UTC, 2025-12-30 09:00:00 UTC, ...]
|
|
236
|
+
#
|
|
237
|
+
# @example Calculate from specific time
|
|
238
|
+
# schedule.next_run_at_times(after: 1.week.from_now, count: 3)
|
|
239
|
+
#
|
|
240
|
+
def next_run_at_times(after: nil, count: 1)
|
|
241
|
+
after ||= Time.current
|
|
242
|
+
times = []
|
|
243
|
+
|
|
244
|
+
if cron?
|
|
245
|
+
# Cron-based calculation
|
|
246
|
+
times = calculate_cron_times(after: after, count: count)
|
|
247
|
+
else
|
|
248
|
+
# Anchor-based calculation
|
|
249
|
+
times = calculate_anchor_times(after: after, count: count)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
times
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Calculate the single next run time.
|
|
256
|
+
#
|
|
257
|
+
# Convenience method that returns only the next run time instead of an array.
|
|
258
|
+
# Equivalent to calling next_run_at_times(after: after, count: 1).first
|
|
259
|
+
#
|
|
260
|
+
# @param after [Time, nil] Calculate time after this (default: Time.current)
|
|
261
|
+
# @return [Time, nil] Next run time, or nil if no more runs
|
|
262
|
+
#
|
|
263
|
+
# @example Get next run time
|
|
264
|
+
# schedule.next_run_at
|
|
265
|
+
# # => 2025-12-29 09:00:00 UTC
|
|
266
|
+
#
|
|
267
|
+
def next_run_at(after: nil)
|
|
268
|
+
next_run_at_times(after: after, count: 1).first
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Check if the schedule has been started.
|
|
272
|
+
#
|
|
273
|
+
# A schedule is considered started if it has any executions
|
|
274
|
+
# (pending, scheduled, or skipped).
|
|
275
|
+
#
|
|
276
|
+
# @return [Boolean] true if schedule has any executions, false otherwise
|
|
277
|
+
#
|
|
278
|
+
# @example Check if schedule is started
|
|
279
|
+
# schedule.started? # => false
|
|
280
|
+
# schedule.start!
|
|
281
|
+
# schedule.started? # => true
|
|
282
|
+
#
|
|
283
|
+
def started?
|
|
284
|
+
executions.exists?
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# Check if schedule uses cron mode.
|
|
288
|
+
#
|
|
289
|
+
# @return [Boolean] true if cron expression is set, false otherwise
|
|
290
|
+
def cron?
|
|
291
|
+
cron.present?
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Check if schedule uses anchor mode.
|
|
295
|
+
#
|
|
296
|
+
# @return [Boolean] true if anchor time is set, false otherwise
|
|
297
|
+
def anchor?
|
|
298
|
+
anchor.present?
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# Get timezone object.
|
|
302
|
+
#
|
|
303
|
+
# Returns an ActiveSupport::TimeZone instance for the schedule's timezone.
|
|
304
|
+
# The timezone object is cached in an instance variable.
|
|
305
|
+
#
|
|
306
|
+
# @return [ActiveSupport::TimeZone] The timezone object
|
|
307
|
+
#
|
|
308
|
+
# @example
|
|
309
|
+
# schedule.timezone = 'America/New_York'
|
|
310
|
+
# schedule.tz # => #<ActiveSupport::TimeZone:0x... @name="America/New_York">
|
|
311
|
+
#
|
|
312
|
+
def tz
|
|
313
|
+
@tz ||= Time.find_zone(timezone)
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# Attributes to cache in schedule executions.
|
|
317
|
+
#
|
|
318
|
+
# Returns a hash of schedule attributes that are cached in each ScheduleExecution's
|
|
319
|
+
# cached_schedule column. This allows executions to run even if the schedule is
|
|
320
|
+
# modified or deleted after creation.
|
|
321
|
+
#
|
|
322
|
+
# @return [Hash] Hash of schedule attributes to cache
|
|
323
|
+
#
|
|
324
|
+
# @api private
|
|
325
|
+
#
|
|
326
|
+
def cacheable_attributes
|
|
327
|
+
{
|
|
328
|
+
name: name,
|
|
329
|
+
job_class: job_class,
|
|
330
|
+
args: args,
|
|
331
|
+
queue: queue,
|
|
332
|
+
priority: priority,
|
|
333
|
+
timezone: timezone,
|
|
334
|
+
anchor: anchor,
|
|
335
|
+
interval: interval,
|
|
336
|
+
interval_unit: interval_unit,
|
|
337
|
+
cron: cron,
|
|
338
|
+
catch_up: catch_up
|
|
339
|
+
}
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
private
|
|
343
|
+
|
|
344
|
+
# Create and save an execution, then enqueue it to Beanstalkd.
|
|
345
|
+
#
|
|
346
|
+
# The execution is enqueued immediately with appropriate delay - Beanstalkd's
|
|
347
|
+
# delayed queue will hold it until run_at. This ensures all future executions
|
|
348
|
+
# are already queued and ready to go.
|
|
349
|
+
#
|
|
350
|
+
# @param after [Time, ScheduleExecution, nil] Calculate after this time/execution
|
|
351
|
+
# @return [ScheduleExecution, nil] The created execution, or nil if no more runs
|
|
352
|
+
#
|
|
353
|
+
# @api private
|
|
354
|
+
#
|
|
355
|
+
def create_execution!(after: nil)
|
|
356
|
+
execution = build_execution(after: after)
|
|
357
|
+
return nil if execution.nil?
|
|
358
|
+
|
|
359
|
+
execution.save!
|
|
360
|
+
execution.enqueue!
|
|
361
|
+
execution
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
# Build an execution record (does not save).
|
|
365
|
+
#
|
|
366
|
+
# Calculates the next two run times and builds an execution with run_at
|
|
367
|
+
# and next_run_at set. The execution is not persisted to the database.
|
|
368
|
+
#
|
|
369
|
+
# @param after [Time, ScheduleExecution, nil] Calculate after this time/execution
|
|
370
|
+
# @return [ScheduleExecution, nil] The built execution, or nil if schedule has no more runs
|
|
371
|
+
#
|
|
372
|
+
# @api private
|
|
373
|
+
#
|
|
374
|
+
def build_execution(after: nil)
|
|
375
|
+
after_time = after.is_a?(ScheduleExecution) ? after.run_at : after
|
|
376
|
+
times = next_run_at_times(after: after_time, count: 2)
|
|
377
|
+
|
|
378
|
+
return nil if times.empty?
|
|
379
|
+
|
|
380
|
+
run_at = times[0]
|
|
381
|
+
next_run_at = times[1] # May be nil if count is 1 or no more runs
|
|
382
|
+
|
|
383
|
+
executions.build(
|
|
384
|
+
run_at: run_at,
|
|
385
|
+
next_run_at: next_run_at,
|
|
386
|
+
cached_schedule: cacheable_attributes
|
|
387
|
+
)
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
# Validate that exactly one scheduling mode is configured.
|
|
391
|
+
#
|
|
392
|
+
# Ensures either anchor+interval+interval_unit OR cron is set, but not both.
|
|
393
|
+
# Also validates that anchor mode has all required fields and interval_unit is valid.
|
|
394
|
+
#
|
|
395
|
+
# @return [void]
|
|
396
|
+
#
|
|
397
|
+
# @api private
|
|
398
|
+
#
|
|
399
|
+
def validate_scheduling_mode!
|
|
400
|
+
if anchor.blank? && cron.blank?
|
|
401
|
+
errors.add(:base, 'Must specify either anchor+interval+interval_unit OR cron')
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
if anchor.present? && cron.present?
|
|
405
|
+
errors.add(:base, 'Cannot specify both anchor and cron modes')
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
if anchor.present?
|
|
409
|
+
if interval.blank?
|
|
410
|
+
errors.add(:interval, 'must be present when using anchor mode')
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
if interval_unit.blank?
|
|
414
|
+
errors.add(:interval_unit, 'must be present when using anchor mode')
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
unless valid_interval_unit?
|
|
418
|
+
errors.add(:interval_unit, 'must be one of: seconds, minutes, hours, days, weeks, months, years')
|
|
419
|
+
end
|
|
420
|
+
end
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
# Validate job class exists.
|
|
424
|
+
#
|
|
425
|
+
# Checks that job_class can be constantized and is either an ActiveJob::Base
|
|
426
|
+
# or Postburner::Job subclass.
|
|
427
|
+
#
|
|
428
|
+
# @return [void]
|
|
429
|
+
#
|
|
430
|
+
# @api private
|
|
431
|
+
#
|
|
432
|
+
def validate_job_class_exists!
|
|
433
|
+
return if job_class.blank?
|
|
434
|
+
|
|
435
|
+
# Try to constantize - if it fails, we'll add an error
|
|
436
|
+
klass = job_class.safe_constantize
|
|
437
|
+
|
|
438
|
+
if klass.nil?
|
|
439
|
+
errors.add(:job_class, "class '#{job_class}' does not exist")
|
|
440
|
+
return
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
# Check if it's a valid job class
|
|
444
|
+
# Use <= to include the class itself, not just subclasses
|
|
445
|
+
unless klass <= ::ActiveJob::Base || klass <= Postburner::Job
|
|
446
|
+
errors.add(:job_class, 'must be an ActiveJob or Postburner::Job subclass')
|
|
447
|
+
end
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
# Validate cron expression syntax.
|
|
451
|
+
#
|
|
452
|
+
# Uses the fugit gem to parse and validate cron expressions.
|
|
453
|
+
# Logs a warning if fugit is not available.
|
|
454
|
+
#
|
|
455
|
+
# @return [void]
|
|
456
|
+
#
|
|
457
|
+
# @api private
|
|
458
|
+
#
|
|
459
|
+
def validate_cron_expression!
|
|
460
|
+
return if cron.blank?
|
|
461
|
+
|
|
462
|
+
begin
|
|
463
|
+
require 'fugit'
|
|
464
|
+
parsed = Fugit::Cron.parse(cron)
|
|
465
|
+
|
|
466
|
+
if parsed.nil?
|
|
467
|
+
errors.add(:cron, 'is not a valid cron expression')
|
|
468
|
+
end
|
|
469
|
+
rescue LoadError
|
|
470
|
+
# fugit gem not loaded, skip validation
|
|
471
|
+
Rails.logger.warn "[Postburner::Schedule] fugit gem not available for cron validation"
|
|
472
|
+
rescue => e
|
|
473
|
+
errors.add(:cron, "validation error: #{e.message}")
|
|
474
|
+
end
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
# Check if interval_unit is valid.
|
|
478
|
+
#
|
|
479
|
+
# @return [Boolean] true if interval_unit is blank or one of the valid units
|
|
480
|
+
#
|
|
481
|
+
# @api private
|
|
482
|
+
#
|
|
483
|
+
def valid_interval_unit?
|
|
484
|
+
return true if interval_unit.blank?
|
|
485
|
+
|
|
486
|
+
%w[seconds minutes hours days weeks months years].include?(interval_unit)
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
# Calculate next run times using cron expression.
|
|
490
|
+
#
|
|
491
|
+
# Uses fugit gem to parse cron expression and calculate future run times.
|
|
492
|
+
# All calculations are performed in the schedule's timezone.
|
|
493
|
+
#
|
|
494
|
+
# @param after [Time] Calculate times after this time
|
|
495
|
+
# @param count [Integer] Number of times to calculate
|
|
496
|
+
# @return [Array<Time>] Array of future run times
|
|
497
|
+
# @raise [RuntimeError] If cron expression is invalid or fugit gem is not available
|
|
498
|
+
#
|
|
499
|
+
# @api private
|
|
500
|
+
#
|
|
501
|
+
def calculate_cron_times(after:, count:)
|
|
502
|
+
require 'fugit'
|
|
503
|
+
|
|
504
|
+
cron_parser = Fugit::Cron.parse(cron)
|
|
505
|
+
raise "Invalid cron expression: #{cron}" if cron_parser.nil?
|
|
506
|
+
|
|
507
|
+
times = []
|
|
508
|
+
current_time = tz.at(after.to_f)
|
|
509
|
+
|
|
510
|
+
count.times do
|
|
511
|
+
next_time = cron_parser.next_time(current_time)
|
|
512
|
+
break if next_time.nil?
|
|
513
|
+
|
|
514
|
+
times << next_time.to_time
|
|
515
|
+
current_time = next_time
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
times
|
|
519
|
+
rescue LoadError
|
|
520
|
+
raise "fugit gem is required for cron-based schedules. Add 'gem \"fugit\"' to your Gemfile"
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
# Calculate next run times using anchor and interval.
|
|
524
|
+
#
|
|
525
|
+
# Grid-aligned scheduling that maintains fixed intervals from anchor point.
|
|
526
|
+
# Never drifts - always calculates N*interval from anchor.
|
|
527
|
+
#
|
|
528
|
+
# @param after [Time] Calculate times after this time
|
|
529
|
+
# @param count [Integer] Number of times to calculate
|
|
530
|
+
# @return [Array<Time>] Array of future run times
|
|
531
|
+
#
|
|
532
|
+
# @api private
|
|
533
|
+
#
|
|
534
|
+
def calculate_anchor_times(after:, count:)
|
|
535
|
+
times = []
|
|
536
|
+
current_time = after
|
|
537
|
+
|
|
538
|
+
count.times do
|
|
539
|
+
next_time = calculate_next_anchor_time(current_time)
|
|
540
|
+
break if next_time.nil?
|
|
541
|
+
|
|
542
|
+
times << next_time
|
|
543
|
+
current_time = next_time
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
times
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
# Calculate next grid-aligned time from anchor.
|
|
550
|
+
#
|
|
551
|
+
# Snaps to the grid defined by anchor + N*interval, never drifts.
|
|
552
|
+
# This is like subscription billing - always on the anchor date/time.
|
|
553
|
+
#
|
|
554
|
+
# Delegates to specific calculation methods based on interval_unit:
|
|
555
|
+
# - Fixed intervals (seconds-weeks): calculate_next_fixed_interval_time
|
|
556
|
+
# - Months: calculate_next_month_time
|
|
557
|
+
# - Years: calculate_next_year_time
|
|
558
|
+
#
|
|
559
|
+
# @param from_time [Time] Calculate next time after this
|
|
560
|
+
# @return [Time] Next grid-aligned time
|
|
561
|
+
# @raise [RuntimeError] If interval_unit is unsupported
|
|
562
|
+
#
|
|
563
|
+
# @api private
|
|
564
|
+
#
|
|
565
|
+
def calculate_next_anchor_time(from_time)
|
|
566
|
+
from_time ||= tz.at(anchor.to_f)
|
|
567
|
+
anchor_time = tz.at(anchor.to_f)
|
|
568
|
+
|
|
569
|
+
case interval_unit
|
|
570
|
+
when 'seconds', 'minutes', 'hours', 'days', 'weeks'
|
|
571
|
+
calculate_next_fixed_interval_time(from_time, anchor_time)
|
|
572
|
+
when 'months'
|
|
573
|
+
calculate_next_month_time(from_time, anchor_time)
|
|
574
|
+
when 'years'
|
|
575
|
+
calculate_next_year_time(from_time, anchor_time)
|
|
576
|
+
else
|
|
577
|
+
raise "Unsupported interval unit: #{interval_unit}"
|
|
578
|
+
end
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
# Calculate next time for fixed-duration intervals (seconds through weeks).
|
|
582
|
+
#
|
|
583
|
+
# Uses integer arithmetic to avoid floating point precision issues.
|
|
584
|
+
# Finds the smallest N such that anchor + N*interval > from_time.
|
|
585
|
+
#
|
|
586
|
+
# @param from_time [Time] Calculate next time after this
|
|
587
|
+
# @param anchor_time [Time] The anchor point for grid alignment
|
|
588
|
+
# @return [Time] Next grid-aligned time
|
|
589
|
+
#
|
|
590
|
+
# @api private
|
|
591
|
+
#
|
|
592
|
+
def calculate_next_fixed_interval_time(from_time, anchor_time)
|
|
593
|
+
interval_seconds = case interval_unit
|
|
594
|
+
when 'seconds' then interval
|
|
595
|
+
when 'minutes' then interval * 60
|
|
596
|
+
when 'hours' then interval * 3600
|
|
597
|
+
when 'days' then interval * 86400
|
|
598
|
+
when 'weeks' then interval * 604800
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
# Use integer seconds to avoid floating point precision issues
|
|
602
|
+
elapsed = (from_time.to_i - anchor_time.to_i)
|
|
603
|
+
|
|
604
|
+
# Find the next grid point strictly AFTER from_time
|
|
605
|
+
# We need to find the smallest N such that anchor + N*interval > from_time
|
|
606
|
+
# That means N > elapsed/interval, so N = floor(elapsed/interval) + 1
|
|
607
|
+
intervals_passed = (elapsed / interval_seconds) + 1
|
|
608
|
+
|
|
609
|
+
# Handle case where from_time is before anchor
|
|
610
|
+
intervals_passed = 1 if intervals_passed < 1
|
|
611
|
+
|
|
612
|
+
anchor_time + (intervals_passed * interval_seconds)
|
|
613
|
+
end
|
|
614
|
+
|
|
615
|
+
# Calculate next time for month-based intervals.
|
|
616
|
+
#
|
|
617
|
+
# Handles variable month lengths like Flex subscription billing.
|
|
618
|
+
# Always snaps to the anchor's day-of-month (or end of month if shorter).
|
|
619
|
+
#
|
|
620
|
+
# Uses Rails Duration for month arithmetic with day adjustment.
|
|
621
|
+
# This ensures "snap back" behavior - e.g., Jan 31 -> Feb 28 -> Mar 31.
|
|
622
|
+
#
|
|
623
|
+
# @param from_time [Time] Calculate next time after this
|
|
624
|
+
# @param anchor_time [Time] The anchor point for grid alignment
|
|
625
|
+
# @return [Time] Next grid-aligned time
|
|
626
|
+
#
|
|
627
|
+
# @example Snap back behavior
|
|
628
|
+
# anchor = Time.zone.parse('2025-01-31 09:00:00')
|
|
629
|
+
# # Next runs: Jan 31, Feb 28 (or 29), Mar 31, Apr 30, May 31, ...
|
|
630
|
+
#
|
|
631
|
+
# @api private
|
|
632
|
+
#
|
|
633
|
+
def calculate_next_month_time(from_time, anchor_time)
|
|
634
|
+
requested_day = anchor_time.day
|
|
635
|
+
|
|
636
|
+
# Estimate starting interval based on months difference
|
|
637
|
+
months_diff = (from_time.year - anchor_time.year) * 12 + (from_time.month - anchor_time.month)
|
|
638
|
+
n = (months_diff.to_f / interval).floor
|
|
639
|
+
n = 0 if n < 0
|
|
640
|
+
|
|
641
|
+
# Find the first grid point strictly AFTER from_time
|
|
642
|
+
loop do
|
|
643
|
+
grid_point = month_grid_point_at(anchor_time, n, requested_day)
|
|
644
|
+
return grid_point if grid_point > from_time
|
|
645
|
+
n += 1
|
|
646
|
+
end
|
|
647
|
+
end
|
|
648
|
+
|
|
649
|
+
# Calculate the monthly grid point for a specific interval number.
|
|
650
|
+
#
|
|
651
|
+
# Uses Rails Duration for month arithmetic and adjusts day to match anchor.
|
|
652
|
+
# This ensures "snap back" behavior - e.g., Jan 31 -> Feb 28 -> Mar 31.
|
|
653
|
+
#
|
|
654
|
+
# @param anchor_time [Time] The anchor time
|
|
655
|
+
# @param n [Integer] Number of intervals from anchor
|
|
656
|
+
# @param requested_day [Integer] The day of month from anchor
|
|
657
|
+
# @return [Time] The grid point time
|
|
658
|
+
#
|
|
659
|
+
# @api private
|
|
660
|
+
#
|
|
661
|
+
def month_grid_point_at(anchor_time, n, requested_day)
|
|
662
|
+
# Use Rails Duration for month arithmetic (handles variable month lengths)
|
|
663
|
+
next_time = anchor_time + (n * interval).months
|
|
664
|
+
|
|
665
|
+
# Adjust day to match anchor's day (or end of month if shorter)
|
|
666
|
+
# This is the Flex pattern for "snap back" behavior
|
|
667
|
+
if next_time.end_of_month.day >= requested_day
|
|
668
|
+
next_time.change(day: requested_day)
|
|
669
|
+
else
|
|
670
|
+
next_time.change(day: next_time.end_of_month.day)
|
|
671
|
+
end
|
|
672
|
+
end
|
|
673
|
+
|
|
674
|
+
# Calculate next time for year-based intervals.
|
|
675
|
+
#
|
|
676
|
+
# Uses Rails Duration for year arithmetic (handles leap years automatically).
|
|
677
|
+
# Finds the smallest N such that anchor + N*interval > from_time.
|
|
678
|
+
#
|
|
679
|
+
# @param from_time [Time] Calculate next time after this
|
|
680
|
+
# @param anchor_time [Time] The anchor point for grid alignment
|
|
681
|
+
# @return [Time] Next grid-aligned time
|
|
682
|
+
#
|
|
683
|
+
# @example Leap year handling
|
|
684
|
+
# anchor = Time.zone.parse('2024-02-29 09:00:00') # Leap year
|
|
685
|
+
# # Next run: 2025-02-28 (Rails Duration handles Feb 29 -> Feb 28)
|
|
686
|
+
#
|
|
687
|
+
# @api private
|
|
688
|
+
#
|
|
689
|
+
def calculate_next_year_time(from_time, anchor_time)
|
|
690
|
+
years_diff = from_time.year - anchor_time.year
|
|
691
|
+
n = (years_diff.to_f / interval).floor
|
|
692
|
+
n = 0 if n < 0
|
|
693
|
+
|
|
694
|
+
# Find the first grid point strictly AFTER from_time
|
|
695
|
+
loop do
|
|
696
|
+
# Rails Duration handles Feb 29 -> Feb 28 automatically
|
|
697
|
+
grid_point = anchor_time + (n * interval).years
|
|
698
|
+
return grid_point if grid_point > from_time
|
|
699
|
+
n += 1
|
|
700
|
+
end
|
|
701
|
+
end
|
|
702
|
+
end
|
|
703
|
+
end
|